From 2a80003cba5beb78b8896c5b08e5ed5ab352d2cf Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Sun, 4 Oct 2026 19:36:04 +0200 Subject: [PATCH 01/59] feat(web): restore stop-thread action in sidebar and header [L02] --- apps/web/src/components/Sidebar.tsx | 45 ++++- .../components/threadActionMenu.logic.test.ts | 45 ++++- .../src/components/threadActionMenu.logic.ts | 16 +- apps/web/src/contextMenuFallback.test.ts | 17 ++ apps/web/src/contextMenuFallback.ts | 1 + apps/web/src/hooks/useThreadActionMenu.ts | 29 ++++ docs/user/thread-sidebar.md | 6 + .../src/operations/commands.test.ts | 161 ++++++++++++++++++ 8 files changed, 315 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3ee0f867c..46341f772 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -102,6 +102,7 @@ import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, + executeAtomQuery, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; @@ -155,6 +156,8 @@ import { } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "../state/orchestration"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useThreadSearch } from "../state/queries"; @@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { + buildDraftActionMenuItems, + buildThreadActionMenuItems, + canStopThreadSession, +} from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -2349,6 +2356,9 @@ export default function Sidebar() { archiveThread, deleteThread, } = useThreadActions(); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -4437,7 +4447,7 @@ export default function Sidebar() { null; // Un-settle pins the thread active until real activity clears the pin. // Environments without - // the settlement capability get no lifecycle items at all. + // the settlement capability get no settlement item. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; @@ -4451,6 +4461,17 @@ export default function Sidebar() { const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; + const projection = await executeAtomQuery( + appAtomRegistry, + orchestrationEnvironment.v2.threadProjection({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + { refresh: true, reportFailure: false }, + ); + const canStopSession = canStopThreadSession( + projection._tag === "Success" ? projection.value.providerSessions : null, + ); const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); @@ -4482,6 +4503,7 @@ export default function Sidebar() { canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), + canStopSession, supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, @@ -4541,6 +4563,24 @@ export default function Sidebar() { } return; } + case "stop-thread": { + if (!canStopSession) return; + const result = await stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to stop thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "settle": attemptSettle(threadRef); return; @@ -4702,6 +4742,7 @@ export default function Sidebar() { setProjectScopeKey, setThreadAutoSettle, startThreadRename, + stopThreadSession, updateThreadMetadata, timestampFormat, ], diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1f85eb1a4..88e34562c 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + canStopThreadSession, buildDraftActionMenuItems, buildThreadActionMenuItems, type ThreadActionMenuState, @@ -16,6 +17,7 @@ const baseState: ThreadActionMenuState = { canSnoozeNow: true, isRegeneratingTitle: false, isRunning: false, + canStopSession: false, supports: { settlement: true, autoSettleOptOut: true, @@ -38,8 +40,39 @@ function allIds(state: ThreadActionMenuState): string[] { return flatten(buildThreadActionMenuItems(state)); } +describe("canStopThreadSession", () => { + it("requires a non-stopped projected session, including an idle or errored attachment", () => { + expect(canStopThreadSession(null)).toBe(false); + expect(canStopThreadSession([])).toBe(false); + expect(canStopThreadSession([{ status: "stopped" }, { status: "stopped" }])).toBe(false); + for (const status of ["starting", "ready", "running", "waiting", "error"] as const) { + expect(canStopThreadSession([{ status: "stopped" }, { status }])).toBe(true); + } + }); +}); + describe("buildThreadActionMenuItems", () => { - it("hides lifecycle items when the environment lacks the capabilities", () => { + it("places Stop after settlement and disables it after all sessions stop", () => { + const items = buildThreadActionMenuItems({ ...baseState, canStopSession: true }); + expect(items[items.findIndex((item) => item.id === "settle") + 1]).toMatchObject({ + id: "stop-thread", + label: "Stop thread", + icon: "square", + disabled: false, + }); + expect( + buildThreadActionMenuItems(baseState).find((item) => item.id === "stop-thread"), + ).toMatchObject({ disabled: true }); + expect( + buildThreadActionMenuItems({ + ...baseState, + isPinned: true, + isSettled: true, + canStopSession: true, + }).find((item) => item.id === "stop-thread"), + ).toMatchObject({ disabled: false }); + }); + it("hides capability-gated items while keeping Stop available as a disabled action", () => { expect( ids({ ...baseState, @@ -51,7 +84,15 @@ describe("buildThreadActionMenuItems", () => { titleRegeneration: false, }, }), - ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); + ).toEqual([ + "stop-thread", + "rename", + "mark-unread", + "copy", + "project-settings", + "archive", + "delete", + ]); }); it("groups project settings with utility actions before archive", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 1b9ada254..2f68ef775 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { OrchestrationV2ProviderSession, ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; /** @@ -12,6 +12,7 @@ export type ThreadActionMenuId = | "project-settings" | "pin" | "unpin" + | "stop-thread" | "settle" | "unsettle" | "auto-settle" @@ -69,6 +70,12 @@ export function buildDraftActionMenuItems(options: { ]; } +export function canStopThreadSession( + sessions: ReadonlyArray> | null, +): boolean { + return sessions?.some((session) => session.status !== "stopped") ?? false; +} + export interface ThreadActionMenuState { readonly branch: string | null; /** @@ -89,6 +96,7 @@ export interface ThreadActionMenuState { readonly isRegeneratingTitle: boolean; /** Archive rejects a thread with an attached provider, so disable it here rather than let the action fail. */ readonly isRunning: boolean; + readonly canStopSession: boolean; readonly supports: { readonly settlement: boolean; /** Server understands thread.auto-settle.set. */ @@ -135,6 +143,12 @@ export function buildThreadActionMenuItems( : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, ] : []), + { + id: "stop-thread", + label: "Stop thread", + icon: "square", + disabled: !state.canStopSession, + }, ...(state.supports.snooze ? [ state.isSnoozed diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index ddc5da05f..37b0ca920 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -219,6 +219,23 @@ afterEach(() => { }); describe("showContextMenuFallback", () => { + it("selects an enabled Stop and rejects a disabled Stop", async () => { + const selection = showContextMenuFallback([ + { id: "stop-thread", label: "Stop thread", icon: "square" }, + ]); + const button = findButton("Stop thread"); + expect(button).toBeTruthy(); + button?.dispatchEvent(new FakeDomEvent("click")); + await expect(selection).resolves.toBe("stop-thread"); + + const disabled = showContextMenuFallback([ + { id: "stop-thread", label: "Stop thread", icon: "square", disabled: true }, + ]); + expect(findButton("Stop thread")?.disabled).toBe(true); + findButton("Stop thread")?.dispatchEvent(new FakeDomEvent("click")); + dismissContextMenu(); + await expect(disabled).resolves.toBeNull(); + }); it("renders one separator between menu sections", async () => { const selectionPromise = showContextMenuFallback([ { id: "rename", label: "Rename" }, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index db14ed8c8..06b08adac 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,6 +4,7 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { + square: [{ tag: "rect", attrs: { width: "18", height: "18", x: "3", y: "3", rx: "2" } }], archive: [ { tag: "rect", attrs: { width: "20", height: "5", x: "2", y: "3", rx: "1" } }, { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } }, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 793ce7e97..1f6f7c600 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,6 +1,7 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { + executeAtomQuery, type AtomCommandResult, isAtomCommandInterrupted, settlePromise, @@ -14,9 +15,12 @@ import { useCallback, useMemo } from "react"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + canStopThreadSession, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "../state/orchestration"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -94,6 +98,9 @@ export function useThreadActionMenu(input: { deleteThread, markThreadUnread, } = useThreadActions(); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -139,6 +146,17 @@ export function useThreadActionMenu(input: { pinning: readEnvironmentSupportsPinning(threadRef.environmentId), titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; + const projection = await executeAtomQuery( + appAtomRegistry, + orchestrationEnvironment.v2.threadProjection({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + { refresh: true, reportFailure: false }, + ); + const canStopSession = canStopThreadSession( + projection._tag === "Success" ? projection.value.providerSessions : null, + ); const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); const items = buildThreadActionMenuItems({ @@ -151,6 +169,7 @@ export function useThreadActionMenu(input: { canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), + canStopSession, supports, snoozePresets, }); @@ -211,6 +230,15 @@ export function useThreadActionMenu(input: { } return; } + case "stop-thread": + if (!canStopSession) return; + await reportFailure("Failed to stop thread", () => + stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + ); + return; case "settle": await reportFailure("Failed to settle thread", () => settleThread(threadRef)); return; @@ -343,6 +371,7 @@ export function useThreadActionMenu(input: { router, setThreadAutoSettle, settleThread, + stopThreadSession, snoozeThread, threadRef, timestampFormat, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb7c4e6a9..2142591b1 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,6 +134,12 @@ 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. +On web and desktop, choose **Stop thread** from the sidebar or chat header menu +to stop the thread's agent sessions while keeping its conversation, pin, and +settlement state. Sending another message starts a session again. Stop leaves +terminals open. Use **Settle thread** to move finished work out of the active +list, or **Delete** to permanently clear the conversation history. + 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. diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a9833f749..2567fb256 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -9,13 +9,16 @@ import { ORCHESTRATION_V2_WS_METHODS, PlanId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, RunId, RuntimeRequestId, ThreadId, TurnItemId, WS_METHODS, type OrchestrationV2Command, + type OrchestrationV2ProviderSession, type OrchestrationV2ThreadLaunchInput, type OrchestrationV2ThreadProjection, type ProjectMutation, @@ -51,6 +54,7 @@ import { revertThreadCheckpoint, settleThread, startThreadTurn, + stopThreadSession, unsettleThread, updateProject, updateThreadMetadata, @@ -833,6 +837,163 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect( + "stops all projected sessions with detach only, preserving thread lifecycle state", + () => + Effect.gen(function* () { + const projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + thread: { ...v2Projection.thread, settledOverride: "active" }, + providerSessions: (["ready", "running"] as const).map( + (status, index): OrchestrationV2ProviderSession => ({ + id: ProviderSessionId.make(`session-${index}`), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status, + cwd: "/workspace/project", + model: null, + capabilities: { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + supportsProviderSwitchingViaHandoff: false, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: false, + canReadThreadSnapshot: false, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: false, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: false, + emitsTurnCompleted: false, + supportsInterrupt: false, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: false, + terminalStatusQuality: "none", + }, + streaming: { + streamsAssistantText: false, + streamsReasoning: false, + streamsToolOutput: false, + streamsPlanText: false, + emitsMessageCompleted: false, + }, + tools: { + exposesToolItemIds: false, + emitsToolStarted: false, + emitsToolCompleted: false, + emitsToolOutput: false, + supportsMcpTools: false, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: false, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: false, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: false, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: false, + canGenerateSummaries: false, + canConsumeHandoffSummaries: false, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: false, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: false, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: false, + }, + identity: { + nativeThreadIds: "none", + nativeTurnIds: "none", + nativeItemIds: "none", + nativeRequestIds: "none", + }, + runtimePolicy: { + enforcement: "client-boundary", + }, + }, + createdAt: v2Now, + updatedAt: v2Now, + lastError: null, + }), + ), + }; + const commands: OrchestrationV2Command[] = []; + const projectionRequests: ThreadId[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + projection, + projectionRequests, + }); + const result = yield* stopThreadSession({ + threadId: v2ThreadId, + commandId: CommandId.make("stop-command"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(projectionRequests).toEqual([v2ThreadId]); + expect(commands).toEqual( + projection.providerSessions.map((session) => ({ + type: "provider-session.detach", + threadId: v2ThreadId, + commandId: `stop-command:detach:${session.id}`, + providerSessionId: session.id, + reason: "client-requested", + })), + ); + expect(result).toEqual({ sequence: 2 }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("does not dispatch any lifecycle command when no provider sessions remain", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + projection: v2Projection, + }); + const result = yield* stopThreadSession({ + threadId: v2ThreadId, + commandId: CommandId.make("stop-empty"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(commands).toEqual([]); + expect(result).toEqual({ sequence: 0 }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("dispatches settle and unsettle commands without timestamps", () => Effect.gen(function* () { const dispatched: OrchestrationV2Command[] = []; From b7b7f447acfdeeaf017e0d75b4cd44753611fbc3 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Sun, 4 Oct 2026 19:58:41 +0200 Subject: [PATCH 02/59] fix(web): preserve draft ownership during failed sends [L03] --- apps/web/src/components/ChatView.tsx | 144 +++---- .../chat/composerSendRecovery.test.ts | 354 ++++++++++++++++++ .../components/chat/composerSendRecovery.ts | 117 ++++++ 3 files changed, 551 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/components/chat/composerSendRecovery.test.ts create mode 100644 apps/web/src/components/chat/composerSendRecovery.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e001dad66..005c4e9b0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,7 +8,7 @@ import { resolveWorktreeSetupProgress, } from "./ChatView.logic"; import * as DateTime from "effect/DateTime"; -import { restorePlanFollowUpComposer } from "./ChatView.logic"; +import { clearSubmittedComposer, restoreFailedComposerSend } from "./chat/composerSendRecovery"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./chat/queuedMessageEdit"; import { @@ -1931,6 +1931,7 @@ export default function ChatView(props: ChatViewProps) { ); const isResuming = resumingThreadKeys.has(routeThreadKey); const composerSendGenerationRef = useRef(0); + const composerRecoveryGenerationRef = useRef(new Map()); const multipleModelSelections = fanoutState.selections; const setMultipleModelSelections = useCallback( (selections: SetStateAction | null>) => { @@ -8711,9 +8712,19 @@ export default function ChatView(props: ChatViewProps) { const followUpReviewComments = [...composerReviewComments]; const followUpPreviewAnnotations = [...composerPreviewAnnotations]; const followUpThreadContexts = [...composerThreadContexts]; - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + const followUpGeneration = ++composerSendGenerationRef.current; + composerRecoveryGenerationRef.current.set(routeThreadKey, followUpGeneration); + const isCurrentFollowUp = () => + composerRecoveryGenerationRef.current.get(routeThreadKey) === followUpGeneration; + const clearedFollowUp = clearSubmittedComposer({ + routeThreadKey, + currentRouteThreadKeyRef, + composerDraftTarget, + promptRef, + expectedDraft: useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + isCurrentSend: isCurrentFollowUp, + resetCursor: () => composerRef.current?.resetCursorState(), + }); const followUpSent = await onSubmitPlanFollowUp({ text: followUp.text, context: buildMessageContext({ @@ -8724,29 +8735,31 @@ export default function ChatView(props: ChatViewProps) { }), interactionMode: followUp.interactionMode, }); - if (!followUpSent) { - promptRef.current = followUpPromptSnapshot; - composerTerminalContextsRef.current = [...followUpTerminalContexts]; - restorePlanFollowUpComposer({ + if (!followUpSent && clearedFollowUp) { + restoreFailedComposerSend({ + routeThreadKey, + currentRouteThreadKeyRef, + composerDraftTarget, + promptRef, + backgroundDraftOpened: false, + composerImagesRef, + composerFilesRef, + composerTerminalContextsRef, + expectedDraft: clearedFollowUp.draft, + isCurrentSend: isCurrentFollowUp, snapshot: { prompt: followUpPromptSnapshot, + images: [], + files: [], terminalContexts: followUpTerminalContexts, reviewComments: followUpReviewComments, previewAnnotations: followUpPreviewAnnotations, threadContexts: followUpThreadContexts, }, - writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt), - writeTerminalContexts: (contexts) => - setComposerDraftTerminalContexts(composerDraftTarget, [...contexts]), - writeReviewComments: (comments) => - setComposerDraftReviewComments(composerDraftTarget, [...comments]), - writePreviewAnnotations: (annotations) => - setComposerDraftPreviewAnnotations(composerDraftTarget, [...annotations]), - writeThreadContexts: (records) => - setComposerDraftThreadContexts(composerDraftTarget, [...records]), resetCursor: (options) => composerRef.current?.resetCursorState(options), }); } else if ( + followUpSent && submissionIntent === "background" && currentRouteThreadKeyRef.current === routeThreadKey ) { @@ -8814,6 +8827,9 @@ export default function ChatView(props: ChatViewProps) { return; } + const submittedComposerDraft = useComposerDraftStore + .getState() + .getComposerDraft(composerDraftTarget); const composerImagesSnapshot = [...composerImages]; const composerFilesSnapshot = [...composerFiles]; const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; @@ -8935,6 +8951,19 @@ export default function ChatView(props: ChatViewProps) { sendInFlightRef.current = true; const sendGeneration = ++composerSendGenerationRef.current; + composerRecoveryGenerationRef.current.set(routeThreadKey, sendGeneration); + const isCurrentComposerSend = () => + composerRecoveryGenerationRef.current.get(routeThreadKey) === sendGeneration; + const clearSendingComposer = () => + clearSubmittedComposer({ + routeThreadKey, + currentRouteThreadKeyRef, + composerDraftTarget, + promptRef, + expectedDraft: submittedComposerDraft, + isCurrentSend: isCurrentComposerSend, + resetCursor: () => composerRef.current?.resetCursorState(), + }); const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { sendInFlightRef.current = false; @@ -9053,10 +9082,7 @@ export default function ChatView(props: ChatViewProps) { composerAttachmentsSnapshot[0]?.name || "New thread", ); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - clearedDraft = true; + clearedDraft = clearSendingComposer() !== null; const clearedDraftSnapshot = useComposerDraftStore .getState() .getComposerDraft(composerDraftTarget); @@ -9363,9 +9389,7 @@ export default function ChatView(props: ChatViewProps) { }), ); } - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + const clearedComposer = clearSendingComposer(); let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -9606,46 +9630,38 @@ export default function ChatView(props: ChatViewProps) { ); clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); } - if ( - backgroundDraftOpened - ? !composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) - : promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) - ?.previewAnnotations.length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.threadContexts - .length ?? 0) === 0 - ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - promptRef.current = messageTextForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerFilesRef.current = composerFilesSnapshot; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, messageTextForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), - prompt: messageTextForSend, - detectTrigger: true, + if (clearedComposer) { + restoreFailedComposerSend({ + routeThreadKey, + currentRouteThreadKeyRef, + backgroundDraftOpened, + composerDraftTarget, + promptRef, + composerImagesRef, + composerFilesRef, + composerTerminalContextsRef, + expectedDraft: clearedComposer.draft, + isCurrentSend: isCurrentComposerSend, + snapshot: { + prompt: messageTextForSend, + images: composerImagesSnapshot, + files: composerFilesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + threadContexts: composerThreadContextsSnapshot, + }, + onRestore: () => { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); + }, + resetCursor: (options) => composerRef.current?.resetCursorState(options), }); } if (!isAtomCommandInterrupted(failure)) { diff --git a/apps/web/src/components/chat/composerSendRecovery.test.ts b/apps/web/src/components/chat/composerSendRecovery.test.ts new file mode 100644 index 000000000..87fd66cf0 --- /dev/null +++ b/apps/web/src/components/chat/composerSendRecovery.test.ts @@ -0,0 +1,354 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useComposerDraftStore } from "../../composerDraftStore"; +import { threadContextRecord } from "../../lib/composerContextRecords"; +import { buildFileReviewComment } from "../../reviewCommentContext"; +import { clearSubmittedComposer, restoreFailedComposerSend } from "./composerSendRecovery"; + +const a = scopeThreadRef(EnvironmentId.make("env-a"), ThreadId.make("thread-a")); +const b = scopeThreadRef(EnvironmentId.make("env-a"), ThreadId.make("thread-b")); +const sameIdOtherEnvironment = scopeThreadRef(EnvironmentId.make("env-b"), a.threadId); +const createdAt = "2026-10-04T00:00:00.000Z"; + +function recovery() { + return { + routeThreadKey: scopedThreadKey(a), + currentRouteThreadKeyRef: { current: scopedThreadKey(a) as string | null }, + composerDraftTarget: a, + backgroundDraftOpened: false as boolean, + expectedDraft: null as Parameters[0]["expectedDraft"], + isCurrentSend: (): boolean => true, + promptRef: { current: "" }, + composerImagesRef: { current: [] }, + composerFilesRef: { current: [] }, + composerTerminalContextsRef: { current: [] }, + snapshot: { + prompt: "original A", + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + }, + resetCursor: vi.fn(), + } satisfies Parameters[0]; +} + +function fullSnapshot(): Parameters[0]["snapshot"] { + const file = new File(["test"], "test.txt", { type: "text/plain" }); + const imageFile = new File(["image"], "image.png", { type: "image/png" }); + return { + prompt: "original A", + images: [ + { + type: "image", + id: "image", + name: imageFile.name, + mimeType: imageFile.type, + sizeBytes: imageFile.size, + previewUrl: "blob:original", + file: imageFile, + }, + ], + files: [ + { + type: "file", + id: "file", + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }, + ], + terminalContexts: [ + { + id: "terminal", + threadId: a.threadId, + terminalId: "default", + terminalLabel: "Terminal", + lineStart: 1, + lineEnd: 1, + text: "output", + createdAt, + }, + ], + previewAnnotations: [ + { + id: "preview", + pageUrl: "https://example.test", + pageTitle: null, + comment: "look", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt, + }, + ], + threadContexts: [threadContextRecord(b, "Related work")], + reviewComments: [ + buildFileReviewComment({ + id: "review", + filePath: "test.ts", + startLine: 1, + endLine: 1, + text: "look", + contents: "test", + }), + ], + }; +} + +function resetStore() { + useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); +} + +beforeEach(resetStore); +afterEach(() => { + resetStore(); + vi.restoreAllMocks(); +}); + +describe("composer send recovery ownership", () => { + it("restores the original same-route prompt and cursor", () => { + const input = recovery(); + expect(restoreFailedComposerSend(input)).toBe(true); + expect(input.promptRef.current).toBe("original A"); + expect(useComposerDraftStore.getState().getComposerDraft(a)?.prompt).toBe("original A"); + expect(input.resetCursor).toHaveBeenCalledWith({ + cursor: 10, + prompt: "original A", + detectTrigger: true, + }); + }); + + it.each(["", "new B"])("preserves B live state while recovering A with B prompt %j", (prompt) => { + const input = recovery(); + input.currentRouteThreadKeyRef.current = scopedThreadKey(b); + input.promptRef.current = prompt; + useComposerDraftStore.getState().setPrompt(b, prompt); + expect(restoreFailedComposerSend(input)).toBe(true); + expect(input.promptRef.current).toBe(prompt); + expect(useComposerDraftStore.getState().getComposerDraft(b)?.prompt ?? "").toBe(prompt); + expect(useComposerDraftStore.getState().getComposerDraft(a)?.prompt).toBe("original A"); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it.each([b, sameIdOtherEnvironment, null])( + "clears only the captured store after navigation to %j", + (route) => { + const input = recovery(); + const store = useComposerDraftStore.getState(); + store.setPrompt(a, "submitted A"); + store.setPrompt(b, "new B"); + input.currentRouteThreadKeyRef.current = route ? scopedThreadKey(route) : null; + input.promptRef.current = "new B"; + clearSubmittedComposer({ ...input, expectedDraft: store.getComposerDraft(a) }); + expect(store.getComposerDraft(a)).toBeNull(); + expect(store.getComposerDraft(b)?.prompt).toBe("new B"); + expect(input.promptRef.current).toBe("new B"); + expect(input.resetCursor).not.toHaveBeenCalled(); + }, + ); + + it("clears the displayed prompt and cursor on the same route", () => { + const input = recovery(); + input.promptRef.current = "submitted A"; + useComposerDraftStore.getState().setPrompt(a, input.promptRef.current); + clearSubmittedComposer({ + ...input, + expectedDraft: useComposerDraftStore.getState().getComposerDraft(a), + }); + expect(input.promptRef.current).toBe(""); + expect(input.resetCursor).toHaveBeenCalledOnce(); + }); + + it("leaves newer same-route input untouched", () => { + const input = recovery(); + input.promptRef.current = "newer A"; + useComposerDraftStore.getState().setPrompt(a, "newer A"); + expect(restoreFailedComposerSend(input)).toBe(false); + expect(input.promptRef.current).toBe("newer A"); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it.each([scopedThreadKey(b), scopedThreadKey(sameIdOtherEnvironment), null])( + "uses A stored content to reject offscreen recovery at %j", + (route) => { + const input = recovery(); + input.currentRouteThreadKeyRef.current = route; + useComposerDraftStore.getState().setPrompt(a, "newer A"); + expect(restoreFailedComposerSend(input)).toBe(false); + expect(useComposerDraftStore.getState().getComposerDraft(a)?.prompt).toBe("newer A"); + expect(input.promptRef.current).toBe(""); + expect(input.resetCursor).not.toHaveBeenCalled(); + }, + ); + + it("treats an opened background draft as offscreen even before the route ref changes", () => { + const input = recovery(); + input.backgroundDraftOpened = true; + input.promptRef.current = "fresh composer"; + expect(restoreFailedComposerSend(input)).toBe(true); + expect(input.promptRef.current).toBe("fresh composer"); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it.each([ + "images", + "files", + "terminalContexts", + "previewAnnotations", + "reviewComments", + "threadContexts", + ] as const)("preserves newer A %s during offscreen recovery", (field) => { + const input = recovery(); + input.currentRouteThreadKeyRef.current = scopedThreadKey(b); + const snapshot = fullSnapshot(); + const store = useComposerDraftStore.getState(); + store.setPrompt(a, "newer A"); + const draft = store.getComposerDraft(a); + useComposerDraftStore.setState((state) => ({ + draftsByThreadKey: { + ...state.draftsByThreadKey, + [scopedThreadKey(a)]: { ...draft!, prompt: "", [field]: snapshot[field] }, + }, + })); + const newerDraft = store.getComposerDraft(a); + expect(restoreFailedComposerSend(input)).toBe(false); + expect(store.getComposerDraft(a)).toBe(newerDraft); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it("recovers every attachment and context in A without changing populated B refs", () => { + const snapshot = fullSnapshot(); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:retry"); + const input: Parameters[0] = { + ...recovery(), + snapshot, + currentRouteThreadKeyRef: { current: scopedThreadKey(b) }, + promptRef: { current: "new B" }, + composerImagesRef: { current: snapshot.images }, + composerFilesRef: { current: snapshot.files }, + composerTerminalContextsRef: { current: snapshot.terminalContexts }, + }; + const store = useComposerDraftStore.getState(); + store.setPrompt(b, "new B"); + const bDraft = store.getComposerDraft(b); + expect(restoreFailedComposerSend(input)).toBe(true); + const draft = store.getComposerDraft(a)!; + expect(draft.prompt).toContain("original A"); + expect(draft.images).toEqual([{ ...snapshot.images[0], previewUrl: "blob:retry" }]); + expect(draft.files).toEqual(snapshot.files); + expect(draft.terminalContexts).toEqual(snapshot.terminalContexts); + expect(draft.previewAnnotations).toEqual(snapshot.previewAnnotations); + expect(draft.reviewComments).toEqual(snapshot.reviewComments); + expect(draft.threadContexts).toEqual(snapshot.threadContexts); + expect(input.composerImagesRef.current).toBe(snapshot.images); + expect(input.composerFilesRef.current).toBe(snapshot.files); + expect(input.composerTerminalContextsRef.current).toBe(snapshot.terminalContexts); + expect(input.promptRef.current).toBe("new B"); + expect(store.getComposerDraft(b)).toBe(bDraft); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); +}); + +function deferredResult() { + let resolve!: (accepted: boolean) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("delayed composer send results", () => { + it("delayed acceptance clears A while preserving B's visible prompt and cursor", async () => { + const input = recovery(); + const store = useComposerDraftStore.getState(); + store.setPrompt(a, "submitted A"); + input.expectedDraft = store.getComposerDraft(a); + const rpc = deferredResult(); + const completion = rpc.promise.then((accepted) => + accepted ? clearSubmittedComposer(input) : null, + ); + input.currentRouteThreadKeyRef.current = scopedThreadKey(b); + input.promptRef.current = "next B"; + store.setPrompt(b, "next B"); + rpc.resolve(true); + expect(await completion).not.toBeNull(); + expect(store.getComposerDraft(a)).toBeNull(); + expect(store.getComposerDraft(b)?.prompt).toBe("next B"); + expect(input.promptRef.current).toBe("next B"); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it("delayed acceptance preserves a newer same-thread draft", async () => { + const input = recovery(); + const store = useComposerDraftStore.getState(); + store.setPrompt(a, "submitted A"); + input.expectedDraft = store.getComposerDraft(a); + const rpc = deferredResult(); + const completion = rpc.promise.then(() => clearSubmittedComposer(input)); + store.setPrompt(a, "newer A"); + input.promptRef.current = "newer A"; + rpc.resolve(true); + expect(await completion).toBeNull(); + expect(store.getComposerDraft(a)?.prompt).toBe("newer A"); + expect(input.promptRef.current).toBe("newer A"); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it("delayed rejection restores A's complete snapshot without writing B refs", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:retry"); + const input: Parameters[0] = { + ...recovery(), + snapshot: fullSnapshot(), + }; + const rpc = deferredResult(); + const completion = rpc.promise.then((accepted) => + accepted ? false : restoreFailedComposerSend(input), + ); + input.currentRouteThreadKeyRef.current = scopedThreadKey(b); + input.promptRef.current = "next B"; + useComposerDraftStore.getState().setPrompt(b, "next B"); + rpc.resolve(false); + expect(await completion).toBe(true); + const draft = useComposerDraftStore.getState().getComposerDraft(a)!; + expect(draft.files).toEqual(input.snapshot.files); + expect(draft.images[0]?.file).toBe(input.snapshot.images[0]?.file); + expect(draft.threadContexts).toEqual(input.snapshot.threadContexts); + expect(input.promptRef.current).toBe("next B"); + expect(input.composerImagesRef.current).toEqual([]); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it("an older rejection cannot resurrect its prompt after a newer send emptied A", async () => { + const input = recovery(); + let currentGeneration = 1; + input.isCurrentSend = () => currentGeneration === 1; + const rpc = deferredResult(); + const completion = rpc.promise.then(() => restoreFailedComposerSend(input)); + const store = useComposerDraftStore.getState(); + store.setPrompt(a, "second send"); + currentGeneration = 2; + store.clearComposerContent(a); + rpc.resolve(false); + expect(await completion).toBe(false); + expect(store.getComposerDraft(a)).toBeNull(); + expect(input.promptRef.current).toBe(""); + expect(input.resetCursor).not.toHaveBeenCalled(); + }); + + it("recovery respects changed attachment revisions even when the prompt stays empty", () => { + const input = recovery(); + const store = useComposerDraftStore.getState(); + store.setPrompt(a, ""); + input.expectedDraft = store.getComposerDraft(a); + store.addFiles(a, fullSnapshot().files); + expect(restoreFailedComposerSend(input)).toBe(false); + expect(store.getComposerDraft(a)?.files).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/chat/composerSendRecovery.ts b/apps/web/src/components/chat/composerSendRecovery.ts new file mode 100644 index 000000000..19615a388 --- /dev/null +++ b/apps/web/src/components/chat/composerSendRecovery.ts @@ -0,0 +1,117 @@ +import { + composerDraftHasUserContent, + useComposerDraftStore, + type ComposerThreadTarget, + type ComposerThreadDraftState, +} from "../../composerDraftStore"; +import { cloneComposerImageForRetry } from "../ChatView.logic"; +import { collapseExpandedComposerCursor } from "../../composer-logic"; + +type Ref = { current: T }; +type SendSnapshot = Pick< + ComposerThreadDraftState, + | "prompt" + | "images" + | "files" + | "terminalContexts" + | "previewAnnotations" + | "reviewComments" + | "threadContexts" +>; +type SendOwner = { + routeThreadKey: string; + currentRouteThreadKeyRef: Ref; + composerDraftTarget: ComposerThreadTarget; + promptRef: Ref; + expectedDraft: ComposerThreadDraftState | null; + isCurrentSend: () => boolean; +}; +type RecoveryCursor = { cursor: number; prompt: string; detectTrigger: boolean }; + +function matchesDraftContent( + current: ComposerThreadDraftState | null, + expected: ComposerThreadDraftState | null, +): boolean { + if (current === expected) return true; + if (current === null || expected === null) return false; + // Next-turn model/mode persistence may change the draft without editing its content. + return ( + current.prompt === expected.prompt && + current.images === expected.images && + current.files === expected.files && + current.terminalContexts === expected.terminalContexts && + current.previewAnnotations === expected.previewAnnotations && + current.reviewComments === expected.reviewComments && + current.threadContexts === expected.threadContexts + ); +} + +export function clearSubmittedComposer( + input: SendOwner & { resetCursor: () => void }, +): { draft: ComposerThreadDraftState | null } | null { + const store = useComposerDraftStore.getState(); + if ( + !input.isCurrentSend() || + !matchesDraftContent(store.getComposerDraft(input.composerDraftTarget), input.expectedDraft) + ) { + return null; + } + const isDisplayed = input.currentRouteThreadKeyRef.current === input.routeThreadKey; + if (isDisplayed) input.promptRef.current = ""; + store.clearComposerContent(input.composerDraftTarget); + if (isDisplayed) input.resetCursor(); + return { draft: store.getComposerDraft(input.composerDraftTarget) }; +} + +export function restoreFailedComposerSend( + input: SendOwner & { + backgroundDraftOpened: boolean; + composerImagesRef: Ref; + composerFilesRef: Ref; + composerTerminalContextsRef: Ref; + snapshot: SendSnapshot; + onRestore?: () => void; + resetCursor: (options: RecoveryCursor) => void; + }, +): boolean { + const isDisplayed = + !input.backgroundDraftOpened && input.currentRouteThreadKeyRef.current === input.routeThreadKey; + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(input.composerDraftTarget); + if (!input.isCurrentSend() || !matchesDraftContent(draft, input.expectedDraft)) return false; + const canRestore = isDisplayed + ? input.promptRef.current.length === 0 && + input.composerImagesRef.current.length === 0 && + input.composerFilesRef.current.length === 0 && + input.composerTerminalContextsRef.current.length === 0 && + (draft?.previewAnnotations.length ?? 0) === 0 && + (draft?.reviewComments.length ?? 0) === 0 && + (draft?.threadContexts.length ?? 0) === 0 + : !composerDraftHasUserContent(draft); + if (!canRestore) return false; + + input.onRestore?.(); + const snapshot = input.snapshot; + const images = snapshot.images.map(cloneComposerImageForRetry); + if (isDisplayed) { + input.promptRef.current = snapshot.prompt; + input.composerImagesRef.current = images; + input.composerFilesRef.current = snapshot.files; + input.composerTerminalContextsRef.current = snapshot.terminalContexts; + } + store.setPrompt(input.composerDraftTarget, snapshot.prompt); + store.addImages(input.composerDraftTarget, images); + store.addFiles(input.composerDraftTarget, snapshot.files); + store.setTerminalContexts(input.composerDraftTarget, snapshot.terminalContexts); + store.setPreviewAnnotations(input.composerDraftTarget, snapshot.previewAnnotations); + store.setReviewComments(input.composerDraftTarget, snapshot.reviewComments); + store.setThreadContexts(input.composerDraftTarget, snapshot.threadContexts); + if (isDisplayed) { + input.resetCursor({ + cursor: collapseExpandedComposerCursor(snapshot.prompt, snapshot.prompt.length), + prompt: snapshot.prompt, + detectTrigger: true, + }); + } + return true; +} From 450b1a1236fde109b54e184365313933a90b3a40 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Sun, 4 Oct 2026 20:31:15 +0200 Subject: [PATCH 03/59] feat(web): restore active-thread sidebar filter [L08] --- apps/web/src/components/LegacySidebar.tsx | 48 +++++- apps/web/src/components/Sidebar.logic.test.ts | 142 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 40 ++++- apps/web/src/components/Sidebar.tsx | 54 +++++-- .../sidebar/SidebarActiveThreadsPill.tsx | 37 +++++ .../src/components/sidebar/SidebarChrome.tsx | 16 +- 6 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarActiveThreadsPill.tsx diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3e1356f98..e970e848b 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -182,6 +182,9 @@ import { buildMultiSelectThreadContextMenuItems, deleteSelectedThreadEntries, getSidebarThreadIdsToPrewarm, + filterSidebarOperatingThreads, + filterSidebarV2VisibleThreads, + isSidebarThreadOperating, resolveAdjacentThreadId, isContextMenuPointerDown, isSidebarNestedLinkClick, @@ -1135,6 +1138,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( }); interface SidebarProjectItemProps { + activeOnly: boolean; project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; @@ -1271,7 +1275,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec // thread-list change). const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; - const projectThreads = sidebarThreads; + const projectThreads = useMemo( + () => + filterSidebarOperatingThreads( + filterSidebarV2VisibleThreads(sidebarThreads, null), + props.activeOnly, + isSidebarThreadOperating, + ), + [sidebarThreads, props.activeOnly], + ); const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); const projectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), @@ -2875,6 +2887,7 @@ function SortableProjectItem({ } interface SidebarProjectsContentProps { + activeOnly: boolean; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -3069,6 +3082,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( {(dragHandleProps) => ( {sortedProjects.map((project) => ( setActiveOnly((value) => !value), []); + const visibleThreads = useMemo( + () => filterSidebarV2VisibleThreads(allSidebarThreads, null), + [allSidebarThreads], + ); + const activeThreadCount = useMemo( + () => visibleThreads.filter(isSidebarThreadOperating).length, + [visibleThreads], + ); + const sidebarThreads = useMemo( + () => filterSidebarOperatingThreads(visibleThreads, activeOnly, isSidebarThreadOperating), + [visibleThreads, activeOnly], + ); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -3183,6 +3212,9 @@ export default function LegacySidebar() { const desktopUpdateState = useDesktopUpdateState(); const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + useEffect(() => { + clearSelection(); + }, [activeOnly, clearSelection]); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); @@ -3420,10 +3452,6 @@ export default function LegacySidebar() { animatedThreadListsRef.current.add(node); }, []); - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ ...project, @@ -3778,9 +3806,15 @@ export default function LegacySidebar() { {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} - + { + const runtime = { + status: "running" as const, + activeRunId: RunId.make("run-operating"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-10-02T12:00:00Z", + }; + const thread = (id: string, overrides: ThreadFixtureOverrides = {}) => + makeThreadFixture({ id: ThreadId.make(id), ...overrides }); + const tasks = [{ taskId: "monitor", kind: "monitor" as const }]; + const threads = [ + thread("pinned", { runtime, pinnedAt: "2026-10-02T12:00:00Z" }), + thread("idle"), + thread("settled-background", { settledOverride: "settled", pendingBackgroundTasks: tasks }), + thread("snoozed-monitor", { + snoozedUntil: "2099-01-01T00:00:00Z", + pendingBackgroundTasks: tasks, + }), + thread("approval", { runtime, hasPendingApprovals: true }), + thread("approval-with-fleet", { hasPendingApprovals: true, pendingBackgroundTasks: tasks }), + thread("archived-running", { archivedAt: "2026-10-02T12:00:00Z", runtime }), + ]; + + it("keeps operating rows in original order across shelves and foreground waits", () => { + expect( + filterSidebarOperatingThreads(threads, true, isSidebarThreadOperating).map((item) => item.id), + ).toEqual(["pinned", "settled-background", "snoozed-monitor", "approval-with-fleet"]); + }); + + it("restores the unchanged collection when the filter is cleared", () => { + expect(filterSidebarOperatingThreads(threads, false, isSidebarThreadOperating)).toBe(threads); + expect(threads).toHaveLength(7); + }); + + it("has no rows when none of the threads are operating", () => { + expect( + filterSidebarOperatingThreads([threads[1]!, threads[4]!], true, isSidebarThreadOperating), + ).toEqual([]); + }); + + it.each(["preparing", "queued", "starting", "running", "waiting"] as const)( + "uses V2 foreground activity status %s", + (status) => + expect( + isSidebarThreadOperating(thread("foreground", { runtime: { ...runtime, status } })), + ).toBe(true), + ); + + it.each(["completed", "failed", "interrupted", "cancelled", "rolled_back", "idle"] as const)( + "does not count inactive runtime status %s without background work", + (status) => + expect( + isSidebarThreadOperating(thread("inactive", { runtime: { ...runtime, status } })), + ).toBe(false), + ); + + it.each(["command", "monitor", "subagent", "background_task"] as const)( + "retains independent %s work during foreground waits", + (kind) => { + for (const wait of [ + { hasPendingApprovals: true }, + { hasPendingUserInput: true }, + { + interactionMode: "plan" as const, + hasActionableProposedPlan: true, + latestRun: makeLatestRun(), + }, + ]) { + const blocked = thread("blocked", { runtime: { ...runtime, activeRunId: null }, ...wait }); + expect(isSidebarThreadOperating(blocked)).toBe(false); + expect( + isSidebarThreadOperating({ + ...blocked, + pendingBackgroundTasks: [{ taskId: "bg", kind }], + }), + ).toBe(true); + } + }, + ); + + it("drops stopped work and restores the idle row when the filter is cleared", () => { + const stopped = thread("pinned", { + runtime: { ...runtime, status: "interrupted", activeRunId: null }, + }); + const stoppedThreads = [stopped, threads[1]!]; + expect(stoppedThreads.filter(isSidebarThreadOperating)).toHaveLength(0); + expect(filterSidebarOperatingThreads(stoppedThreads, true, isSidebarThreadOperating)).toEqual( + [], + ); + expect(filterSidebarOperatingThreads(stoppedThreads, false, isSidebarThreadOperating)).toBe( + stoppedThreads, + ); + }); + + it("removes an archived thread even when background work remains", () => { + const background = thread("background", { pendingBackgroundTasks: tasks }); + expect(isSidebarThreadOperating(background)).toBe(true); + expect(isSidebarThreadOperating({ ...background, archivedAt: "2026-10-02T12:00:00Z" })).toBe( + false, + ); + }); + + it("stops counting background work when its projected roster clears", () => { + const background = thread("background", { pendingBackgroundTasks: tasks }); + expect(isSidebarThreadOperating(background)).toBe(true); + const finished = { ...background, pendingBackgroundTasks: [] }; + expect(isSidebarThreadOperating(finished)).toBe(false); + expect(filterSidebarOperatingThreads([finished], true, isSidebarThreadOperating)).toEqual([]); + expect(filterSidebarOperatingThreads([finished], false, isSidebarThreadOperating)).toEqual([ + finished, + ]); + }); + + it("counts only visible scoped parent threads before search narrows the rows", () => { + const running = thread("parent", { runtime, title: "Other task" }); + const match = thread("match", { runtime, title: "Needle" }); + const child = thread("child", { + runtime, + lineage: { + rootThreadId: running.id, + parentThreadId: running.id, + relationshipToParent: "subagent", + }, + }); + const archived = thread("archived", { runtime, archivedAt: "2026-10-02T12:00:00Z" }); + const elsewhere = thread("elsewhere", { runtime, projectId: ProjectId.make("other-project") }); + const visible = filterSidebarV2VisibleThreads( + [running, child, match, archived, elsewhere], + new Set([`${running.environmentId}:${running.projectId}`]), + ); + const active = filterSidebarOperatingThreads(visible, true, isSidebarThreadOperating); + expect(visible.filter(isSidebarThreadOperating)).toHaveLength(2); + expect(active.map((item) => item.id)).toEqual(["parent", "match"]); + expect(searchSidebarThreads(active, "Needle").map((item) => item.id)).toEqual(["match"]); + expect(visible.filter(isSidebarThreadOperating)).toHaveLength(2); + }); +}); + describe("resolveSidebarRowAccessibility", () => { it.each([ { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3ad4ac09b..459e1b015 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,7 @@ -import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { + threadRuntimeIsActive, + resolveThreadWorkingStartedAt, +} from "@t3tools/client-runtime/state/models"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; @@ -27,6 +30,41 @@ import { cn } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; +export function filterSidebarOperatingThreads( + threads: ReadonlyArray, + activeOnly: boolean, + isOperating: (thread: T) => boolean, +): ReadonlyArray { + return activeOnly ? threads.filter(isOperating) : threads; +} + +export function isSidebarThreadOperating( + thread: Pick< + SidebarThreadSummary, + | "archivedAt" + | "runtime" + | "latestRun" + | "interactionMode" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "hasActionableProposedPlan" + | "pendingBackgroundTasks" + >, +): boolean { + if (thread.archivedAt !== null) return false; + // The projected roster is independent activity, even while the foreground waits. + if (thread.pendingBackgroundTasks.length > 0) return true; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if ( + thread.interactionMode === "plan" && + thread.hasActionableProposedPlan && + isLatestRunSettled(thread.latestRun, thread.runtime) + ) { + return false; + } + return threadRuntimeIsActive(thread.runtime); +} + export function shouldNavigateAfterThreadPark(input: { readonly threadKey: string; readonly currentThreadKey: string | null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 46341f772..fac806711 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -182,6 +182,8 @@ import { animateSidebarLayoutChanges, applySidebarThreadDrop, filterSidebarV2VisibleThreads, + filterSidebarOperatingThreads, + isSidebarThreadOperating, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, @@ -2608,6 +2610,20 @@ export default function Sidebar() { ), [scopedProjectGroup], ); + const [activeOnly, setActiveOnly] = useState(false); + const toggleActiveOnly = useCallback(() => setActiveOnly((value) => !value), []); + const scopedThreads = useMemo( + () => filterSidebarV2VisibleThreads(threads, scopedProjectKeys), + [threads, scopedProjectKeys], + ); + const activeThreadCount = useMemo( + () => scopedThreads.filter(isSidebarThreadOperating).length, + [scopedThreads], + ); + const filteredThreads = useMemo( + () => filterSidebarOperatingThreads(scopedThreads, activeOnly, isSidebarThreadOperating), + [scopedThreads, activeOnly], + ); // A persisted scope whose project is gone falls back to all projects, but // only after every catalog environment has a live project snapshot. Cached // or disconnected environments cannot establish that the project is gone. @@ -2625,6 +2641,7 @@ export default function Sidebar() { // an open never-left draft, which only softens the empty state. const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; const visibleDraftSessionCount = useComposerDraftStore((store) => { + if (activeOnly) return 0; let count = 0; for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { if (session.promotedTo != null) { @@ -2643,11 +2660,11 @@ export default function Sidebar() { } return count; }); - // Scope flips drop the selection: rows selected under the old scope may be - // hidden now, and bulk actions must never count or touch invisible rows. + // Scope or activity filter flips drop the selection: previously selected + // rows may be hidden, and bulk actions must never touch invisible rows. useEffect(() => { clearSelection(); - }, [clearSelection, projectScopeKey]); + }, [activeOnly, clearSelection, projectScopeKey]); const openProjectSettings = useCallback( (projectGroup: SidebarProjectSnapshot) => { @@ -2716,7 +2733,7 @@ export default function Sidebar() { const preciseNow = new Date().toISOString(); // Subagent child threads live in the parent's Agents surface, not the // sidebar roster (v2 models them as real threads with lineage). - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); + const visible = filteredThreads; inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -2817,9 +2834,9 @@ export default function Sidebar() { snoozeNow: preciseNow, }; }, [ + filteredThreads, nowMinute, optimisticDrop, - scopedProjectKeys, serverConfigs, snoozeWakeTick, threads, @@ -4868,7 +4885,12 @@ export default function Sidebar() { return ( <> - + , + !activeOnly ? ( + + ) : null, ]; for (const item of sidebarListItems) { if (item.kind === "thread") { diff --git a/apps/web/src/components/sidebar/SidebarActiveThreadsPill.tsx b/apps/web/src/components/sidebar/SidebarActiveThreadsPill.tsx new file mode 100644 index 000000000..adab2a240 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarActiveThreadsPill.tsx @@ -0,0 +1,37 @@ +import { cn } from "../../lib/utils"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; + +export function SidebarActiveThreadsPill({ + count, + activeOnly, + onToggle, +}: { + count: number; + activeOnly: boolean; + onToggle: () => void; +}) { + const label = activeOnly ? "Show all threads" : "Show only active threads"; + return ( + + 0 ? "text-success-foreground" : "text-muted-foreground/60", + activeOnly ? "bg-success/20 ring-1 ring-success/30" : "bg-muted/30 hover:bg-muted/60", + )} + > + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ce2ef2629..a0db338db 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -6,6 +6,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { usePullRequestsSupported } from "../../state/environments"; +import { SidebarActiveThreadsPill } from "./SidebarActiveThreadsPill"; import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, @@ -32,8 +33,14 @@ import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, + activeThreadCount, + activeOnly, + onToggleActiveOnly, }: { isElectron: boolean; + activeThreadCount?: number; + activeOnly?: boolean; + onToggleActiveOnly?: () => void; }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); @@ -60,10 +67,17 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ variant={backdropVariant ? "media-navigation" : "ghost"} className="relative top-auto z-10 translate-y-0 md:hidden" /> - {/* One visible line: the pill wraps onto the clipped second line once it no longer fits. + {/* One visible line: the pills wrap onto the clipped second line once it no longer fits. The padding keeps the brand's focus ring inside the clip. */}
+ {onToggleActiveOnly ? ( + + ) : null} {pillLabel ? (
From 6f7b7b655fb7675e3966041cfd2dd55a13da8e92 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Sun, 4 Oct 2026 20:54:08 +0200 Subject: [PATCH 04/59] feat: promote next queued message with empty Enter --- apps/web/src/components/ChatView.tsx | 5 + apps/web/src/components/chat/ChatComposer.tsx | 51 ++++++ .../chat/QueuedRunsControl.test.tsx | 158 +++++++++++++++- .../chat/composerSubmission.test.ts | 173 +++++++++++++++++- .../src/components/chat/composerSubmission.ts | 39 ++++ 5 files changed, 423 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 005c4e9b0..0ab3c81bf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4516,6 +4516,10 @@ export default function ChatView(props: ChatViewProps) { reportFailure: false, }); const queuedRunsControlRef = useRef(null); + const onSteerNextQueuedMessage = useCallback(() => { + if (sendInFlightRef.current) return false; + return queuedRunsControlRef.current?.steerNext(false) ?? false; + }, [sendInFlightRef]); const queuedEditSaveInFlightRef = useRef(false); const [isSavingQueuedEdit, setIsSavingQueuedEdit] = useState(false); const queuedEditImageResources = useMemo( @@ -11290,6 +11294,7 @@ export default function ChatView(props: ChatViewProps) { onPageScrollKeyUp={onComposerPageScrollKeyUp} onPageScrollRelease={onComposerPageScrollRelease} onCompactContext={onCompactContext} + onSteerNextQueuedMessage={onSteerNextQueuedMessage} onSend={onSend} onResume={onResume} onInterrupt={onInterrupt} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8cd297925..ea823198f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -319,6 +319,7 @@ import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, submitComposerDraft, + handleComposerEnter, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { PierreEntryIcon } from "./PierreEntryIcon"; @@ -1634,6 +1635,7 @@ export interface ChatComposerProps { // Callbacks onCompactContext: () => void; + onSteerNextQueuedMessage: () => boolean; onSend: ( e?: { preventDefault: () => void }, dispatchMode?: ComposerDispatchMode, @@ -1758,6 +1760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyUp, onPageScrollRelease, onCompactContext, + onSteerNextQueuedMessage, onSend, onResume, onInterrupt, @@ -4369,6 +4372,54 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); } + if ( + key === "Enter" && + handleComposerEnter({ + event, + intent: { + keybindings, + isMobileViewport, + isDraftThread: routeKind === "draft", + isRunning: phase === "running", + sendShortcut: settings.sendShortcut, + prompt: promptRef.current, + }, + hasDraftContext: + composerImagesRef.current.length > 0 || + composerFilesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 || + composerThreadContexts.length > 0 || + (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 || + pendingDraftWork.has(attachmentTargetKey), + queueActionDisabled: + noProviderAvailable || + isSendDisabled || + isSendBusy || + isConnecting || + isRevertingCheckpoint === true || + projectSelectionRequired || + environmentUnavailable !== null || + isEditingQueuedMessage || + activePendingApproval !== null || + pendingUserInputs.length > 0 || + showPlanFollowUpPrompt, + onSteerNextQueuedMessage, + onSubmit: (intent) => + submitComposer( + undefined, + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: intent === "alternate", + activeTurnDefault: settings.followUpBehavior, + }), + intent, + ), + }) + ) { + return true; + } if (submissionIntent) { submitComposer( undefined, diff --git a/apps/web/src/components/chat/QueuedRunsControl.test.tsx b/apps/web/src/components/chat/QueuedRunsControl.test.tsx index 5aa400bb0..4e34e29a8 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.test.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.test.tsx @@ -1,9 +1,13 @@ +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act, createRef, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ projection: null as unknown, workflow: null as unknown, + promote: vi.fn(async (_input: unknown): Promise => undefined), })); vi.mock("@t3tools/client-runtime/environment", () => ({ @@ -27,7 +31,8 @@ vi.mock("../../state/threads", () => ({ })); vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => async () => undefined, + useAtomCommand: (command: symbol) => + command.description === "promoteQueuedRun" ? state.promote : async () => undefined, })); vi.mock("../../assets/assetUrls", () => ({ @@ -35,7 +40,24 @@ vi.mock("../../assets/assetUrls", () => ({ resources.map((resource) => `https://assets.test/${resource.attachmentId}`), })); -import { QueuedRunsControl } from "./QueuedRunsControl"; +// Keep the control's queue and lock lifecycle while replacing DOM positioning and measurement. +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: ComponentProps<"div">) => <>{children}, + TooltipTrigger: ({ render, children }: { render: ReactNode; children: ReactNode }) => ( + <> + {render} + {children} + + ), + TooltipPopup: ({ children }: ComponentProps<"div">) => <>{children}, +})); + +vi.mock("../ui/scroll-area", () => ({ + ScrollArea: ({ children }: ComponentProps<"div">) =>
{children}
, +})); + +import { QueuedRunsControl, type QueuedRunsControlHandle } from "./QueuedRunsControl"; +import { handleComposerEnter } from "./composerSubmission"; describe("QueuedRunsControl automatic completion delivery", () => { it("does not render a queue control when only hidden delivery remains", () => { @@ -169,3 +191,135 @@ describe("QueuedRunsControl attachments and edit mode", () => { expect(html).toContain("Queued with a screenshot"); }); }); + +describe("QueuedRunsControl empty composer Enter", () => { + it("uses one queue item in order and shares the send lock with the arrow action", async () => { + const ref = createRef(); + const onSubmit = vi.fn(); + let finishSend: (() => void) | undefined; + state.promote.mockClear(); + state.promote.mockImplementation( + () => + new Promise((resolve) => { + finishSend = resolve; + }), + ); + const first = { + run: { id: "run:first", userMessageId: "message:first" }, + text: "first", + attachments: [], + }; + const second = { + run: { id: "run:second", userMessageId: "message:second" }, + text: "second", + attachments: [], + }; + const workflow = { + activeRun: { id: "run:active" }, + canPromoteToSteer: true, + canReorder: true, + queuedRuns: [second, first], + }; + state.workflow = workflow; + state.projection = { projection: { messages: [] } }; + const control = () => ( + undefined} + onCancelEdit={() => undefined} + /> + ); + const enter = (repeat = false) => + handleComposerEnter({ + event: { + key: "Enter", + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + isComposing: false, + keyCode: 13, + repeat, + }, + intent: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + platform: "Linux", + isMobileViewport: false, + isDraftThread: false, + isRunning: true, + prompt: "", + }, + hasDraftContext: false, + queueActionDisabled: false, + onSteerNextQueuedMessage: () => ref.current?.steerNext(false) ?? false, + onSubmit, + }); + let renderer: ReactTestRenderer | undefined; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + try { + await act(async () => { + renderer = create(control()); + }); + const rowAction = renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Steer"))!; + expect(rowAction).toBeDefined(); + // Enter and a row click race before React can publish its busy state. + await act(async () => { + expect(enter()).toBe(true); + rowAction.props.onClick(); + expect(enter()).toBe(true); + }); + expect(state.promote).toHaveBeenCalledExactlyOnceWith({ + environmentId: "environment:test", + input: { threadId: "thread:test", queuedRunId: "run:second", targetRunId: "run:active" }, + }); + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + finishSend!(); + }); + state.workflow = { ...workflow, queuedRuns: [first] }; + state.projection = { projection: { messages: [] } }; + await act(async () => { + renderer!.update(control()); + }); + await act(async () => { + expect(enter(true)).toBe(true); + }); + expect(state.promote).toHaveBeenCalledTimes(1); + await act(async () => { + expect(enter()).toBe(true); + }); + expect(state.promote).toHaveBeenLastCalledWith({ + environmentId: "environment:test", + input: { threadId: "thread:test", queuedRunId: "run:first", targetRunId: "run:active" }, + }); + expect(state.promote).toHaveBeenCalledTimes(2); + await act(async () => { + finishSend!(); + }); + state.workflow = { ...workflow, queuedRuns: [] }; + state.projection = { projection: { messages: [] } }; + await act(async () => { + renderer!.update(control()); + }); + expect(enter()).toBe(true); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith("foreground"); + expect(state.promote).toHaveBeenCalledTimes(2); + } finally { + await act(async () => { + finishSend?.(); + renderer?.unmount(); + }); + state.promote.mockReset(); + state.promote.mockImplementation(async () => undefined); + state.projection = null; + state.workflow = null; + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 9808a145d..5c4ade407 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -11,7 +11,13 @@ import { } from "@t3tools/shared/assistantCitations"; import { describe, expect, it, vi } from "vite-plus/test"; -import { submitComposerDraft } from "./composerSubmission"; +import { + DEFAULT_RESOLVED_KEYBINDINGS, + compileResolvedKeybindingsConfig, + mergeWithDefaultKeybindings, +} from "@t3tools/shared/keybindings"; + +import { handleComposerEnter, submitComposerDraft } from "./composerSubmission"; const assistantCitation = { version: 1 as const, @@ -292,3 +298,168 @@ describe("submitComposerDraft", () => { expect(onSend).toHaveBeenCalledOnce(); }); }); + +const bareEnter = { + key: "Enter", + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + isComposing: false, + keyCode: 13, + repeat: false, +}; + +const enterIntent = { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + platform: "Linux", + isMobileViewport: false, + isDraftThread: false, + isRunning: true, + prompt: "", +}; + +function enterScenario(overrides: Partial[0]> = {}) { + const onSubmit = vi.fn(); + const onSteerNextQueuedMessage = vi.fn(() => true); + const options = { + event: bareEnter, + intent: enterIntent, + hasDraftContext: false, + queueActionDisabled: false, + onSubmit, + onSteerNextQueuedMessage, + ...overrides, + }; + return { handled: handleComposerEnter(options), onSubmit, onSteerNextQueuedMessage }; +} + +describe("composer Enter actions", () => { + it("uses the queue action instead of submitting an empty draft", () => { + const result = enterScenario(); + expect(result.handled).toBe(true); + expect(result.onSteerNextQueuedMessage).toHaveBeenCalledOnce(); + expect(result.onSubmit).not.toHaveBeenCalled(); + }); + + it("does not drain the next message on a held Enter key", () => { + const result = enterScenario({ event: { ...bareEnter, repeat: true } }); + expect(result.handled).toBe(true); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).not.toHaveBeenCalled(); + }); + + it("retains normal submission when no message is queued", () => { + const result = enterScenario({ onSteerNextQueuedMessage: vi.fn(() => false) }); + expect(result.onSubmit).toHaveBeenCalledExactlyOnceWith("foreground"); + }); + + it.each(["hello", " ", "\n"])("retains the draft send path for %j", (prompt) => { + const result = enterScenario({ + intent: { ...enterIntent, prompt }, + }); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).toHaveBeenCalledExactlyOnceWith("foreground"); + }); + + it.each(["hasDraftContext", "queueActionDisabled"] as const)("preserves %s guards", (guard) => { + const result = enterScenario({ [guard]: true }); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).toHaveBeenCalledExactlyOnceWith("foreground"); + }); + + it.each(["shiftKey", "altKey", "metaKey", "ctrlKey"] as const)( + "does not steer with %s", + (modifier) => { + const result = enterScenario({ event: { ...bareEnter, [modifier]: true } }); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + if (modifier === "shiftKey") { + expect(result.handled).toBe(false); + expect(result.onSubmit).not.toHaveBeenCalled(); + } + }, + ); + + it.each([{ isComposing: true }, { keyCode: 229 }])("ignores IME Enter %j", (event) => { + const result = enterScenario({ event: { ...bareEnter, ...event } }); + expect(result.handled).toBe(false); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).not.toHaveBeenCalled(); + }); + + it("allows bare queue Enter without changing the configured draft shortcut", () => { + const intent = { + ...enterIntent, + sendShortcut: "mod-enter" as const, + prompt: "", + }; + const empty = enterScenario({ intent }); + expect(empty.onSteerNextQueuedMessage).toHaveBeenCalledOnce(); + const draft = enterScenario({ intent: { ...intent, prompt: "hello" } }); + expect(draft.handled).toBe(false); + expect(draft.onSubmit).not.toHaveBeenCalled(); + }); +}); + +describe("composer Enter with V2 submission routes", () => { + it.each([ + [false, true, false, "alternate"], + [true, false, false, "background"], + [true, false, true, "background"], + [false, false, true, "background"], + ] as const)( + "keeps modified Enter for draft=%s running=%s alt=%s", + (isDraftThread, isRunning, altKey, expected) => { + const result = enterScenario({ + event: { ...bareEnter, ctrlKey: true, altKey }, + intent: { ...enterIntent, isDraftThread, isRunning }, + }); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).toHaveBeenCalledExactlyOnceWith(expected); + }, + ); + + it("honors a remapped Enter action without replacing the newer send routes", () => { + const keybindings = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig([ + { + key: "alt+enter", + command: "composer.sendAlternate", + when: "composerFocus && turnRunning", + }, + ]), + ); + const result = enterScenario({ + event: { ...bareEnter, altKey: true }, + intent: { ...enterIntent, keybindings, prompt: "draft" }, + }); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).toHaveBeenCalledExactlyOnceWith("alternate"); + }); + + it("preserves a background send remapped to bare Enter", () => { + const keybindings = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig([ + { + key: "enter", + command: "composer.sendBackground", + when: "composerFocus && draftThreadRoute", + }, + ]), + ); + const result = enterScenario({ + intent: { ...enterIntent, keybindings, isDraftThread: true }, + }); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).toHaveBeenCalledExactlyOnceWith("background"); + }); + + it("leaves mobile Enter on its existing newline path", () => { + const result = enterScenario({ + intent: { ...enterIntent, isMobileViewport: true }, + }); + expect(result.handled).toBe(false); + expect(result.onSteerNextQueuedMessage).not.toHaveBeenCalled(); + expect(result.onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index 9e35aca99..88f1edb0b 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -1,6 +1,11 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; +import { + composerSubmissionIntentForKey, + type ComposerSubmissionIntent, +} from "../../composer-logic"; + type ComposerSubmitEvent = { preventDefault: () => void }; type ComposerSubmissionInput = { @@ -48,3 +53,37 @@ export function submitComposerDraft( } return { validationMessage: null, didDispatch: true }; } + +export function handleComposerEnter(options: { + event: Pick< + KeyboardEvent, + "key" | "shiftKey" | "altKey" | "metaKey" | "ctrlKey" | "isComposing" | "keyCode" | "repeat" + >; + intent: Omit[0], "event">; + hasDraftContext: boolean; + queueActionDisabled: boolean; + onSteerNextQueuedMessage: () => boolean; + onSubmit: (intent: ComposerSubmissionIntent) => void; +}): boolean { + const { event } = options; + if (event.key !== "Enter" || event.isComposing || event.keyCode === 229) return false; + const intent = composerSubmissionIntentForKey({ ...options.intent, event }); + if ( + (intent === null || intent === "foreground") && + !options.intent.isMobileViewport && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + options.intent.prompt === "" && + !options.hasDraftContext && + !options.queueActionDisabled + ) { + // Held Enter must not drain the queue after a completed send. The queue's + // existing steer lock also covers a row action racing a fresh key press. + if (event.repeat || options.onSteerNextQueuedMessage()) return true; + } + if (!intent) return false; + options.onSubmit(intent); + return true; +} From 37eee3bdf147b08e303f8f0045ac2da1a9e28e4c Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Sun, 4 Oct 2026 22:11:30 +0200 Subject: [PATCH 05/59] fix(codex): preserve account-local SQLite maintenance locks [L13] --- .../provider/Drivers/CodexHomeLayout.test.ts | 62 +++++++++++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2c78ffd18..8dbb5efd8 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -85,6 +85,68 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }); describe("materializeCodexShadowHome", () => { + it.effect.skipIf(!symlinksSupported)( + "preserves existing account-local SQLite maintenance locks", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const lockName = ".sqlite-maintenance.lock"; + yield* writeTextFile(path.join(sharedHome, lockName), "shared-lock"); + yield* writeTextFile(path.join(shadowHome, lockName), "account-lock"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readFileString(path.join(shadowHome, lockName))).toBe( + "account-lock", + ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, lockName))).toBe( + "shared-lock", + ); + expect(yield* fileSystem.readLink(path.join(shadowHome, "sessions"))).toBe( + path.join(sharedHome, "sessions"), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "does not introduce a shared SQLite maintenance lock into an account home", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const lockName = ".sqlite-maintenance.lock"; + yield* writeTextFile(path.join(sharedHome, lockName), "shared-lock"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout); + expect(yield* fileSystem.exists(path.join(shadowHome, lockName))).toBe(false); + + yield* writeTextFile(path.join(shadowHome, lockName), "new-account-lock"); + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readFileString(path.join(shadowHome, lockName))).toBe( + "new-account-lock", + ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, lockName))).toBe( + "shared-lock", + ); + expect(yield* fileSystem.readLink(path.join(shadowHome, "sessions"))).toBe( + path.join(sharedHome, "sessions"), + ); + }), + ); + it.effect.skipIf(!symlinksSupported)( "materializes a shadow home with shared state links and private auth", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 8efd0b5d9..04dc84212 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -30,7 +30,7 @@ const KNOWN_SHARED_DIRECTORIES = [ ] as const; const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); -const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); +const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".sqlite-maintenance.lock"]); const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); function resolveHomePath(path: Path.Path, value: string | undefined): string { From 293d3993459b1e7640f326cc7228313c850af555 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Sun, 4 Oct 2026 23:59:50 +0200 Subject: [PATCH 06/59] Prove OpenCode buffered replies survive native stream exit --- .../Adapters/OpenCode2AdapterV2.test.ts | 69 ++++++++++++ .../Adapters/OpenCodeAdapterV2.test.ts | 100 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 6df5f827f..6f4f7bd6e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2227,6 +2227,75 @@ describe("OpenCode2 adapter", () => { ); }); + it.effect.each(["text", "reasoning"] as const)( + "finalizes buffered %s before a lost OpenCode 2 stream ends the session", + (kind) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event(`session.${kind}.started`, { + sessionID: SESSION, + assistantMessageID: "msg_buffered_exit", + ordinal: 0, + ...(kind === "reasoning" ? { state: { reasoningField: "reasoning_content" } } : {}), + }), + event(`session.${kind}.delta`, { + sessionID: SESSION, + assistantMessageID: "msg_buffered_exit", + ordinal: 0, + delta: "Answer preserved across provider exit.", + }), + { type: "runtime_exit", status: "success" }, + ]); + const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + yield* TestClock.adjust("1 minute"); + const collected = Array.from(yield* Fiber.join(events)); + const itemType = kind === "text" ? "assistant_message" : "reasoning"; + const items = collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === itemType + ? [event.turnItem] + : [], + ); + const first = items[0]!; + const last = items.at(-1)!; + assert.equal(first.status, "running"); + assert.equal(last.status, "completed"); + if (last.type !== "assistant_message" && last.type !== "reasoning") { + return assert.fail("Expected buffered text or reasoning item"); + } + assert.equal(last.text, "Answer preserved across provider exit."); + assert.isFalse(last.streaming); + assert.equal(last.id, first.id); + assert.equal(last.providerTurnId, first.providerTurnId); + assert.lengthOf( + items.filter((item) => item.status === "completed"), + 1, + ); + const completionIndex = collected.findIndex( + (event) => event.type === "turn_item.updated" && event.turnItem === last, + ); + const terminalIndex = collected.findIndex((event) => event.type === "turn.terminal"); + assert.isTrue(completionIndex < terminalIndex); + assert.deepInclude(collected[terminalIndex], { + status: "failed", + threadDisposition: "broken", + }); + if (kind === "text") { + const message = collected.findLast((event) => event.type === "message.updated"); + assert.equal( + message?.type === "message.updated" ? message.message.text : undefined, + last.text, + ); + assert.equal( + message?.type === "message.updated" ? message.message.streaming : undefined, + false, + ); + } + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("fails the session once reconnecting to a lost event stream has given up", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index a53c9b20b..ff2a3e28a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -1616,6 +1616,106 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); + it.effect.each(["text", "reasoning"] as const)( + "finalizes buffered %s before an active OpenCode event stream exits", + (kind) => + Effect.gen(function* () { + const nativeSessionId = `native-opencode-buffered-exit-${kind}`; + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness( + `buffered-exit-${kind}`, + nativeSessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { + once: true, + }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }, + ); + const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + yield* harness.startTurn(); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + part: { + id: "part-buffered-exit", + sessionID: nativeSessionId, + messageID: "message-buffered-exit", + type: kind, + text: "Answer preserved", + time: { start: DateTime.toEpochMillis(harness.now) }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.delta", + properties: { + sessionID: nativeSessionId, + messageID: "message-buffered-exit", + partID: "part-buffered-exit", + field: "text", + delta: " across provider exit.", + }, + }), + ); + nativeEvents.close(); + const received = Array.from(yield* Fiber.join(events)); + const itemType = kind === "text" ? "assistant_message" : "reasoning"; + const items = received.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === itemType + ? [event.turnItem] + : [], + ); + const first = items[0]!; + const last = items.at(-1)!; + assert.equal(first.status, "running"); + assert.equal(last.status, "completed"); + if (last.type !== "assistant_message" && last.type !== "reasoning") { + return assert.fail("Expected buffered text or reasoning item"); + } + assert.equal(last.text, "Answer preserved across provider exit."); + assert.isFalse(last.streaming); + assert.equal(last.id, first.id); + assert.equal(last.providerTurnId, first.providerTurnId); + assert.equal(last.runId, harness.runId); + assert.lengthOf( + items.filter((item) => item.status === "completed"), + 1, + ); + const completionIndex = received.findIndex( + (event) => event.type === "turn_item.updated" && event.turnItem === last, + ); + const terminalIndex = received.findIndex((event) => event.type === "turn.terminal"); + assert.isTrue(completionIndex < terminalIndex); + const terminal = received[terminalIndex]; + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : undefined, "failed"); + if (kind === "text") { + const message = received.findLast((event) => event.type === "message.updated"); + assert.equal( + message?.type === "message.updated" ? message.message.text : undefined, + last.text, + ); + assert.equal( + message?.type === "message.updated" ? message.message.streaming : undefined, + false, + ); + } + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + it.effect("fails an active turn when the OpenCode event stream ends cleanly", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); From ffe1830e9c8e675e9532ecc9885792303baae594 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 00:29:45 +0200 Subject: [PATCH 07/59] fix(checkpoints): reject capture in primary Git checkouts --- .../src/checkpointing/CheckpointStore.test.ts | 84 +++++-- apps/server/src/sourceControl/GitLabCli.ts | 2 + apps/server/src/vcs/GitVcsDriver.test.ts | 231 +++++++++++------- apps/server/src/vcs/GitVcsDriver.ts | 19 ++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 5 + docs/internals/checkpoint-safety.md | 15 ++ packages/contracts/src/vcs.ts | 14 ++ 7 files changed, 268 insertions(+), 102 deletions(-) create mode 100644 docs/internals/checkpoint-safety.md diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 8aa58d4af..44975b93a 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -88,6 +88,16 @@ function initRepoWithCommit( }); } +const initLinkedWorktree = Effect.fn("initLinkedWorktree")(function* (root: string) { + const repository = NodePath.join(root, "repository"); + const worktree = NodePath.join(root, "worktree"); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(repository, { recursive: true }); + yield* initRepoWithCommit(repository); + yield* git(repository, ["worktree", "add", "-b", "checkpoint-test", worktree]); + return worktree; +}); + function buildLargeText(lineCount = 5_000): string { return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) .join("\n") @@ -127,11 +137,51 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { expect(yield* checkpointStore.isGitRepository(nested)).toBe(true); }), ); + describe("captureCheckpoint", () => { + it.effect.each(["clean", "dirty"] as const)( + "refuses %s primary checkouts without writing checkpoint refs", + (state) => + Effect.gen(function* () { + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const cwd = yield* makeTmpDir(`checkpoint-store-primary-${state}-`); + yield* initRepoWithCommit(cwd); + if (state === "dirty") { + yield* writeTextFile(NodePath.join(cwd, "README.md"), "dirty primary checkout\n"); + } + const checkpointRef = checkpointRefForThreadTurn( + ThreadId.make(`thread-primary-checkout-${state}`), + 0, + ); + const fileSystem = yield* FileSystem.FileSystem; + yield* git(cwd, ["checkout", "-b", "primary-safety-test"]); + const originalIndex = yield* fileSystem.readFile(NodePath.join(cwd, ".git", "index")); + const originalObjects = yield* git(cwd, ["count-objects", "-v"]); + const originalMetadata = yield* fileSystem.readDirectory(NodePath.join(cwd, ".git")); + const result = yield* checkpointStore + .captureCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.result); + + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "VcsPrimaryCheckoutCheckpointError" }, + }); + expect(yield* checkpointStore.hasCheckpointRef({ cwd, checkpointRef })).toBe(false); + expect(yield* fileSystem.readFile(NodePath.join(cwd, ".git", "index"))).toEqual( + originalIndex, + ); + expect(yield* git(cwd, ["count-objects", "-v"])).toBe(originalObjects); + expect(yield* fileSystem.readDirectory(NodePath.join(cwd, ".git"))).toEqual( + originalMetadata, + ); + }), + ); + }); + describe("diffCheckpoints", () => { it.effect("returns full oversized checkpoint diffs without truncation", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const root = yield* makeTmpDir(); + const tmp = yield* initLinkedWorktree(root); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); @@ -162,8 +212,8 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const root = yield* makeTmpDir(); + const tmp = yield* initLinkedWorktree(root); yield* git(tmp, ["config", "diff.noprefix", "true"]); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); @@ -193,8 +243,8 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { it.effect("can hide indentation churn when changes wrap existing lines", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const root = yield* makeTmpDir(); + const tmp = yield* initLinkedWorktree(root); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); @@ -287,8 +337,8 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { describe("checkpoint file summaries", () => { it.effect("counts changes whose full patch exceeds the output limit", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const root = yield* makeTmpDir(); + const tmp = yield* initLinkedWorktree(root); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("large-checkpoint-summary"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); @@ -317,8 +367,8 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { it.effect("preserves file paths and turn ranges without changing the user index", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const root = yield* makeTmpDir(); + const tmp = yield* initLinkedWorktree(root); yield* git(tmp, ["config", "diff.renames", "copies"]); const fileSystem = yield* FileSystem.FileSystem; const checkpointStore = yield* CheckpointStore.CheckpointStore; @@ -358,7 +408,13 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { yield* writeTextFile(NodePath.join(tmp, path), contents); } yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: firstTurn }); - const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index")); + const indexPath = yield* git(tmp, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ]); + const userIndex = yield* fileSystem.readFile(indexPath); const input = { cwd: tmp, fromCheckpointRef: baseline, @@ -408,14 +464,14 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { expect( yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: baseline }), ).toBe(""); - expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex); + expect(yield* fileSystem.readFile(indexPath)).toEqual(userIndex); }), ); it.effect("uses HEAD for a missing baseline only when requested", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const root = yield* makeTmpDir(); + const tmp = yield* initLinkedWorktree(root); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("checkpoint-summary-fallback"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9d03e1ab8..51ed5f05d 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -153,6 +153,8 @@ export class GitLabCliCommandError extends Schema.TaggedError new GitLabCliCommandError({ ...context, cause }), VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsPrimaryCheckoutCheckpointError: (cause) => + new GitLabCliCommandError({ ...context, cause }), VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); } diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 6b8920501..9880a719e 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -98,23 +98,30 @@ runVcsDriverContractSuite({ const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( driver: Effect.Success>, - cwd: string, + root: string, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const repository = path.join(root, "repository"); + const cwd = path.join(root, "worktree"); + yield* fileSystem.makeDirectory(repository); + const primaryGit = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd: repository, args }); + yield* primaryGit(["init"]); + yield* primaryGit(["config", "user.name", "Test"]); + yield* primaryGit(["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(repository, "file.txt"), "initial\n"); + yield* primaryGit(["add", "."]); + yield* primaryGit(["commit", "-m", "initial"]); + yield* primaryGit(["worktree", "add", "-b", "checkpoint-test", cwd]); const git = (args: ReadonlyArray) => driver.execute({ operation: "checkpoint-test", cwd, args }); - yield* git(["init"]); - yield* git(["config", "user.name", "Test"]); - yield* git(["config", "user.email", "test@test.com"]); - yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "initial\n"); - yield* git(["add", "."]); - yield* git(["commit", "-m", "initial"]); + const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"])).stdout.trim(); const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); yield* git(["add", "."]); yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); - return { git, checkpointRef }; + return { cwd, git, gitDir, checkpointRef }; }); it.effect("checkpoint capture skips untracked nested repositories without a commit", () => @@ -122,8 +129,8 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-unborn-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-unborn-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); const nested = "scratch/empty [repo]"; yield* git(["init", nested]); yield* git(["init", "another empty"]); @@ -143,7 +150,7 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm ]); const nestedHead = (yield* git(["-C", "committed", "rev-parse", "HEAD"])).stdout.trim(); yield* fileSystem.writeFileString(path.join(cwd, "untracked.txt"), "new\n"); - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fileSystem.readFile(path.join(gitDir, "index")); yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); @@ -155,7 +162,7 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm (yield* git(["ls-tree", checkpointRef, "--", "committed"])).stdout, `160000 commit ${nestedHead}\tcommitted\n`, ); - assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fileSystem.readFile(path.join(gitDir, "index")), originalIndex); assert.strictEqual( yield* fileSystem.readFileString(path.join(cwd, nested, "private.txt")), "nested\n", @@ -168,14 +175,14 @@ it.effect("checkpoint recovery discovers nested HEAD independently of inherited const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-git-dir-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-git-dir-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* git(["init", "empty"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fs.readFile(path.join(gitDir, "index")); yield* Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.GIT_DIR; - process.env.GIT_DIR = path.join(cwd, ".git"); + process.env.GIT_DIR = gitDir; return previous; }), () => driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), @@ -187,7 +194,7 @@ it.effect("checkpoint recovery discovers nested HEAD independently of inherited ); assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", "empty"])).stdout, ""); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fs.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -196,21 +203,21 @@ it.effect("checkpoint capture still fails when a clean filter rejects a file", ( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-filter-failure-", }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* fileSystem.writeFileString(path.join(cwd, ".gitattributes"), "file.txt filter=reject\n"); yield* git(["config", "filter.reject.clean", "false"]); yield* git(["config", "filter.reject.required", "true"]); - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fileSystem.readFile(path.join(gitDir, "index")); const result = yield* Effect.result( driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), ); assert.strictEqual(result._tag, "Failure"); - assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fileSystem.readFile(path.join(gitDir, "index")), originalIndex); assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -220,8 +227,8 @@ it.effect("checkpoint capture refuses a truncated nested repository listing", () const fs = yield* FileSystem.FileSystem; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-truncated-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-truncated-" }); + const { cwd, git, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* git(["init", "empty"]); const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( Effect.provideService(VcsProcess.VcsProcess, { @@ -251,12 +258,12 @@ it.effect("checkpoint recovery refuses excessive candidates before probing", () const path = yield* Path.Path; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-cap-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-cap-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* git(["init", "empty0"]); for (let i = 1; i < 65; i++) yield* fs.copy(path.join(cwd, "empty0"), path.join(cwd, `empty${i}`)); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fs.readFile(path.join(gitDir, "index")); let stageError: VcsProcessExitError | undefined; let nestedProbes = 0; let stageAttempts = 0; @@ -282,7 +289,7 @@ it.effect("checkpoint recovery refuses excessive candidates before probing", () assert.strictEqual(result._tag, "Failure"); if (result._tag === "Failure") assert.strictEqual(result.failure, stageError); assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fs.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -300,11 +307,12 @@ it.effect.each([ const path = yield* Path.Path; const liveRunner = yield* ProcessRunner.ProcessRunner; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ref-race-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ref-race-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); if (nestedRecovery) yield* git(["init", "empty"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - const refLockPath = path.join(cwd, ".git", `${checkpointRef}.lock`); + const originalIndex = yield* fs.readFile(path.join(gitDir, "index")); + const gitCommonDir = (yield* git(["rev-parse", "--git-common-dir"])).stdout.trim(); + const refLockPath = path.join(gitCommonDir, `${checkpointRef}.lock`); const failed = yield* Deferred.make(); const retryReached = yield* Deferred.make(); const allowRetry = yield* Deferred.make(); @@ -388,7 +396,7 @@ it.effect.each([ assert.isFalse(yield* fs.exists(index)); assert.isFalse(yield* fs.exists(`${index}.lock`)); } - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fs.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), ); @@ -400,10 +408,10 @@ it.effect.each(["discovery", "probe", "retry"] as const)( const path = yield* Path.Path; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-timeout-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-timeout-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* git(["init", "empty"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fs.readFile(path.join(gitDir, "index")); const entered = yield* Deferred.make(); const discovered = yield* Deferred.make(); let stageError: VcsProcessExitError | undefined; @@ -477,7 +485,7 @@ it.effect.each(["discovery", "probe", "retry"] as const)( assert.isFalse(yield* fs.exists(privateIndex!)); assert.isFalse(yield* fs.exists(`${privateIndex!}.lock`)); assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fs.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -486,8 +494,8 @@ it.effect("checkpoint recovery preserves interruption and removes the private in const fs = yield* FileSystem.FileSystem; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-interrupt-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-interrupt-" }); + const { cwd, git, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* git(["init", "empty"]); const entered = yield* Deferred.make(); let privateIndex: string | undefined; @@ -520,31 +528,31 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* fileSystem.writeFileString( path.join(cwd, ".gitattributes"), "stable.txt filter=probe\n", ); yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); yield* fileSystem.writeFileString( - path.join(cwd, ".git", "filter.cjs"), - 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + path.join(gitDir, "filter.cjs"), + 'require("node:fs").appendFileSync(require("node:path").join(__dirname, "filter-runs"), "read\\n"); process.stdin.pipe(process.stdout);', ); - yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* git(["config", "filter.probe.clean", `node "${path.join(gitDir, "filter.cjs")}"`]); yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); yield* git(["add", "."]); yield* git(["commit", "-m", "record stable file"]); - yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); + yield* fileSystem.writeFileString(path.join(gitDir, "filter-runs"), ""); yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fileSystem.readFile(path.join(gitDir, "index")); yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); + assert.strictEqual(yield* fileSystem.readFileString(path.join(gitDir, "filter-runs")), ""); assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); - assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fileSystem.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -561,8 +569,8 @@ it.effect.each( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { cwd, gitDir, git } = yield* makeCheckpointFixture(driver, root); const write = Effect.fn(function* (name: string, contents: string) { yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); yield* fs.writeFileString(path.join(cwd, name), contents); @@ -594,7 +602,7 @@ it.effect.each( yield* write("scope/out/new file", "new outside cone\n"); yield* write("elsewhere/file", "working outside\n"); yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); + const indexPath = path.join(gitDir, "index"); if (indexState.endsWith("missing")) yield* fs.remove(indexPath); const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); const captureCwd = nested ? path.join(cwd, "scope") : cwd; @@ -652,10 +660,10 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse" const path = yield* Path.Path; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-legacy-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-legacy-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* git(["sparse-checkout", "set", "--cone", "included"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const originalIndex = yield* fs.readFile(path.join(gitDir, "index")); const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( Effect.provideService(VcsProcess.VcsProcess, { run: (input) => { @@ -682,7 +690,7 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse" ); yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + assert.deepEqual(yield* fs.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -694,18 +702,18 @@ it.effect.each(["normal", "flags", "sparse"] as const)( const path = yield* Path.Path; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); yield* fs.makeDirectory(path.join(cwd, "excluded")); yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); yield* fs.writeFileString( - path.join(cwd, ".git/filter.cjs"), - 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', + path.join(gitDir, "filter.cjs"), + 'require("node:fs").appendFileSync(require("node:path").join(__dirname, "reads"), "read\\n"); process.stdin.pipe(process.stdout);', ); - yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* git(["config", "filter.probe.clean", `node "${path.join(gitDir, "filter.cjs")}"`]); yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); yield* git(["add", "."]); yield* git(["commit", "-m", "inspection fixture"]); @@ -713,8 +721,8 @@ it.effect.each(["normal", "flags", "sparse"] as const)( if (indexMode === "sparse") yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); - yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + yield* fs.writeFileString(path.join(gitDir, "reads"), ""); + const originalIndex = yield* fs.readFile(path.join(gitDir, "index")); const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( Effect.provideService(VcsProcess.VcsProcess, { run: (input) => @@ -735,8 +743,8 @@ it.effect.each(["normal", "flags", "sparse"] as const)( yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n"); if (indexMode !== "flags") - assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + assert.strictEqual(yield* fs.readFileString(path.join(gitDir, "reads")), ""); + assert.deepEqual(yield* fs.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -747,10 +755,10 @@ it.effect.each([1_700_000_000, 1_700_000_000.9999])( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); const filePath = path.join(cwd, "file.txt"); - const indexPath = path.join(cwd, ".git", "index"); + const indexPath = path.join(gitDir, "index"); yield* git(["config", "core.trustctime", "false"]); yield* fileSystem.writeFileString(filePath, "before\n"); yield* fileSystem.utimes(filePath, timestamp, timestamp); @@ -782,8 +790,8 @@ it.effect.each( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { cwd, gitDir, git } = yield* makeCheckpointFixture(driver, root); const write = Effect.fn(function* (name: string, contents: string) { yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); yield* fs.writeFileString(path.join(cwd, name), contents); @@ -815,7 +823,7 @@ it.effect.each( yield* write("scope/out/new file", "new outside cone\n"); yield* write("elsewhere/file", "working outside\n"); yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); + const indexPath = path.join(gitDir, "index"); if (indexState.endsWith("missing")) yield* fs.remove(indexPath); const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); const captureCwd = nested ? path.join(cwd, "scope") : cwd; @@ -873,10 +881,10 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde const path = yield* Path.Path; const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-reset-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-reset-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); const racyPath = path.join(cwd, "racy.txt"); - const indexPath = path.join(cwd, ".git", "index"); + const indexPath = path.join(gitDir, "index"); const timestamp = 1_700_000_000; yield* git(["config", "core.trustctime", "false"]); yield* fileSystem.writeFileString(racyPath, "before\n"); @@ -921,8 +929,8 @@ it.effect.each( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); + const { cwd, gitDir, git } = yield* makeCheckpointFixture(driver, root); const write = (name: string, contents: string) => fileSystem.writeFileString(path.join(cwd, name), contents); yield* fileSystem.makeDirectory(path.join(cwd, "scope")); @@ -948,7 +956,7 @@ it.effect.each( if (indexMode === "split") { yield* git(["update-index", "--split-index"]); } - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + const originalIndex = yield* fileSystem.readFile(path.join(gitDir, "index")); for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { yield* write(name, "working\n"); } @@ -984,7 +992,7 @@ it.effect.each( (yield* git(["show", `${second}:scope/second`])).stdout, "added in second turn\n", ); - assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepEqual(yield* fileSystem.readFile(path.join(gitDir, "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); @@ -995,9 +1003,9 @@ it.effect.each(["missing", "invalid"] as const)( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - const indexPath = path.join(cwd, ".git", "index"); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" }); + const { cwd, git, gitDir, checkpointRef } = yield* makeCheckpointFixture(driver, root); + const indexPath = path.join(gitDir, "index"); if (indexState === "missing") { yield* fileSystem.remove(indexPath); } else { @@ -1021,15 +1029,21 @@ it.effect("restores empty checkpoints without changing paths outside the workspa const path = yield* Path.Path; const driver = yield* GitVcsDriver.makeVcsDriverShape(); for (const nested of [false, true]) { - const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-empty-checkpoint-" }); - yield* runGit(root, ["init"]); - yield* runGit(root, ["config", "user.email", "test@test.com"]); - yield* runGit(root, ["config", "user.name", "Test"]); + const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-empty-checkpoint-", + }); + const repository = path.join(temporaryRoot, "repository"); + const root = path.join(temporaryRoot, "worktree"); + yield* fileSystem.makeDirectory(repository); + yield* runGit(repository, ["init"]); + yield* runGit(repository, ["config", "user.email", "test@test.com"]); + yield* runGit(repository, ["config", "user.name", "Test"]); if (nested) { - yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "original\n"); - yield* runGit(root, ["add", "."]); + yield* fileSystem.writeFileString(path.join(repository, "outside.txt"), "original\n"); + yield* runGit(repository, ["add", "."]); } - yield* runGit(root, ["commit", "--allow-empty", "-m", "initial"]); + yield* runGit(repository, ["commit", "--allow-empty", "-m", "initial"]); + yield* runGit(repository, ["worktree", "add", "-b", "checkpoint-test", root]); const cwd = nested ? path.join(root, "nested") : root; yield* fileSystem.makeDirectory(cwd, { recursive: true }); const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/empty"); @@ -1052,7 +1066,7 @@ it.effect("restores empty checkpoints without changing paths outside the workspa assert.isFalse(yield* fileSystem.exists(addedPath)); } yield* fileSystem.writeFileString( - path.join(root, ".git", "info", "exclude"), + path.join(repository, ".git", "info", "exclude"), "ignored.txt\n", ); yield* fileSystem.writeFileString(path.join(cwd, "ignored.txt"), "keep\n"); @@ -1176,7 +1190,9 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ? "commit0000\n" : input.args.includes("--git-common-dir") ? ".git\n" - : ""; + : input.args.includes("--git-dir") + ? ".git/worktrees/checkpoint-test\n" + : ""; return { exitCode: ChildProcessSpawner.ExitCode(0), stdout, @@ -1190,3 +1206,42 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ), ); }); + +it.effect("refuses primary checkout capture before running index, object, or ref writers", () => { + const commands: Array> = []; + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const result = yield* driver.checkpoints + .captureCheckpoint({ + cwd: "/synthetic-primary", + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/primary-refusal"), + }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") return assert.fail("Expected primary checkout refusal"); + assert.equal(result.failure._tag, "VcsPrimaryCheckoutCheckpointError"); + assert.deepEqual(commands, [ + ["-C", "/synthetic-primary", "rev-parse", "--git-common-dir"], + ["-C", "/synthetic-primary", "rev-parse", "--git-dir"], + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + commands.push(input.args); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: ".git\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + ), + ); +}); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 99220a8bf..8dd8b7664 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -12,6 +12,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + VcsPrimaryCheckoutCheckpointError, VcsProcessExitError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -786,6 +787,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); }); + const resolveGitDir = (cwd: string) => + Effect.gen(function* () { + const result = yield* execute({ + operation: "GitVcsDriver.checkpoints.resolveGitDir", + cwd, + args: ["rev-parse", "--git-dir"], + }); + const gitDir = result.stdout.trim(); + return path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir); + }); + // Git renames loose objects and refs into place without fsync by default, so // an unclean restart can leave 0-byte files under refs/t3/** that break every // later fetch and push. Checkpoint writes flush before they are published; @@ -807,6 +819,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "sparse.expectFilesOutsideOfPatterns=false", ]; const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + const gitDir = yield* resolveGitDir(input.cwd); + if (path.normalize(gitDir) === path.normalize(gitCommonDir)) { + return yield* new VcsPrimaryCheckoutCheckpointError({ + operation, + cwd: input.cwd, + }); + } const tempIndexPath = path.join( gitCommonDir, `t3-checkpoint-index-${NodeCrypto.randomUUID()}`, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c720a2e49..fd61a360e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1594,6 +1594,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "tab\tand\nnewline.txt", "unusual path\n"); } yield* git(cwd, ["add", "."]); + if ((yield* HostProcessPlatform) !== "win32") { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.chmod(path.join(cwd, "mode-only.sh"), 0o755); + } yield* git(cwd, ["update-index", "--chmod=+x", "mode-only.sh"]); yield* git(cwd, ["commit", "-m", "rename and add files"]); const preview = yield* driver.getReviewDiffPreview({ diff --git a/docs/internals/checkpoint-safety.md b/docs/internals/checkpoint-safety.md new file mode 100644 index 000000000..db767b1c6 --- /dev/null +++ b/docs/internals/checkpoint-safety.md @@ -0,0 +1,15 @@ +# Checkpoint safety + +Checkpoint snapshots stage working files into hidden Git refs. Capturing the physical primary +checkout could retain unrelated dirty files in the shared repository even when they were never +committed on a visible branch. Capture therefore requires a linked Git worktree, independent of +branch name or working-tree cleanliness. + +The boundary belongs in `GitVcsDriver.checkpoints.captureCheckpoint` so all callers are covered. +It compares the per-worktree and common Git directories before creating an index, commit, or ref, +and returns `VcsPrimaryCheckoutCheckpointError` when they identify the primary checkout. +Checkpoint reads and restoration retain their existing behavior. + +Tests use disposable repositories with linked worktrees. A fixture's index belongs to its +per-worktree Git directory; checkpoint refs belong to the common Git directory. Resolve those +paths through Git instead of assuming `.git` is a directory in the workspace. diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index dec0e46b6..55a790ebe 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -273,6 +273,19 @@ export class VcsUnsupportedOperationError extends Schema.TaggedError()( + "VcsPrimaryCheckoutCheckpointError", + { + operation: Schema.String, + cwd: Schema.String, + }, +) { + override get message(): string { + return `VCS checkpoint capture refused in ${this.operation}: ${this.cwd} is Git's primary checkout.`; + } +} + export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, @@ -283,5 +296,6 @@ export const VcsError = Schema.Union([ VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, + VcsPrimaryCheckoutCheckpointError, ]); export type VcsError = typeof VcsError.Type; From 4f14b117fb715a94e879ef23127b964f94d6b795 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 00:36:52 +0200 Subject: [PATCH 08/59] test(checkpoints): verify primary refusal retires local run --- .../CheckpointCaptureService.test.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index d9efb6232..d8a4d0bf8 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -14,6 +14,7 @@ import { ProviderThreadId, RunId, ThreadId, + VcsPrimaryCheckoutCheckpointError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -51,10 +52,12 @@ const modelSelection = { } as const; it.layer(ProjectionStoreTestLayer)("CheckpointCaptureServiceV2", (it) => { - it.effect.each([false, true])( + it.effect.each([false, true, "primary checkout refusal"] as const)( "captures without decoding history or losing newer delegated completion, ref lookup fails=%s", - (refLookupFails) => + (captureCase) => Effect.gen(function* () { + const refLookupFails = captureCase === true; + const primaryCheckout = captureCase === "primary checkout refusal"; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; const later = DateTime.add(now, { seconds: 1 }); @@ -269,14 +272,22 @@ it.layer(ProjectionStoreTestLayer)("CheckpointCaptureServiceV2", (it) => { Layer.provide( Layer.mergeAll( IdAllocator.layer, - refLookupFails + refLookupFails || primaryCheckout ? CheckpointService.layer.pipe( Layer.provide( Layer.mergeAll( IdAllocator.layer, Layer.mock(CheckpointStore.CheckpointStore)({ isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, + captureCheckpoint: () => + primaryCheckout + ? Effect.fail( + new VcsPrimaryCheckoutCheckpointError({ + operation: "test.primaryCheckoutCapture", + cwd: "/repo", + }), + ) + : Effect.void, hasCheckpointRef: () => Effect.fail( new VcsProcessTimeoutError({ @@ -332,10 +343,13 @@ it.layer(ProjectionStoreTestLayer)("CheckpointCaptureServiceV2", (it) => { const capturedEvent = events.find((event) => event.type === "checkpoint.captured"); assert.equal( runUpdated.payload.checkpointId, - refLookupFails ? capturedEvent?.payload.id : captured.id, + refLookupFails || primaryCheckout ? capturedEvent?.payload.id : captured.id, ); - if (refLookupFails && capturedEvent?.type === "checkpoint.captured") { - assert.equal(capturedEvent.payload.status, "ready"); + if ( + (refLookupFails || primaryCheckout) && + capturedEvent?.type === "checkpoint.captured" + ) { + assert.equal(capturedEvent.payload.status, primaryCheckout ? "error" : "ready"); assert.deepEqual(capturedEvent.payload.files, []); } assert.isUndefined( From 4a313089c5f4c8aad6297e1fbb4592fa85cdddfd Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 01:08:55 +0200 Subject: [PATCH 09/59] feat(mcp): defer caller self-settlement until successful completion [L23] --- apps/server/src/mcp/OrchestratorMcpService.ts | 28 ++ ...OrchestratorMcpToolkit.integration.test.ts | 12 + .../src/mcp/toolkits/orchestrator/handlers.ts | 6 + .../mcp/toolkits/orchestrator/tools.test.ts | 12 + .../src/mcp/toolkits/orchestrator/tools.ts | 16 + .../src/orchestration-v2/EffectWorker.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 244 ++++++++++++- .../orchestration-v2/ProjectionStore.test.ts | 43 +++ .../src/orchestration-v2/ProjectionStore.ts | 7 + .../ProviderSessionManager.test.ts | 33 ++ .../ProviderSessionManager.ts | 19 + .../ProviderTurnControlService.test.ts | 1 + .../orchestration-v2/SelfSettlement.test.ts | 322 ++++++++++++++++ .../src/orchestration-v2/SelfSettlement.ts | 144 ++++++++ .../ThreadManagementService.ts | 2 + .../src/orchestration-v2/runtimeLayer.test.ts | 345 ++++++++++++++++++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + packages/contracts/src/orchestrationV2.ts | 11 + .../contracts/src/orchestratorMcp.test.ts | 29 ++ packages/contracts/src/orchestratorMcp.ts | 13 + 20 files changed, 1283 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/orchestration-v2/SelfSettlement.test.ts create mode 100644 apps/server/src/orchestration-v2/SelfSettlement.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 83a7a4cbf..cde5aa99b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -30,6 +30,8 @@ import { type OrchestratorMcpTaskCancelResult, type OrchestratorMcpUpdateScheduledTaskInput, type OrchestratorMcpThreadDetail, + type OrchestratorMcpThreadSettleInput, + type OrchestratorMcpThreadSettleResult, type OrchestratorMcpThreadInterruptInput, type OrchestratorMcpThreadInterruptResult, type OrchestratorMcpThreadListInput, @@ -90,6 +92,10 @@ type TerminalTaskStatus = Extract< >; export interface OrchestratorMcpServiceShape { + readonly settleThread: ( + scope: McpInvocationScope, + input: OrchestratorMcpThreadSettleInput, + ) => Effect.Effect; readonly capabilities: ( scope: McpInvocationScope, ) => Effect.Effect; @@ -1198,6 +1204,28 @@ const make = Effect.gen(function* () { }); return OrchestratorMcpService.of({ + settleThread: (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const intent = yield* threadManagement + .requestSelfSettlement({ + threadId: scope.threadId, + mcpCredentialId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + commandId: stableCommandId({ + scope, + requestKey: input.clientRequestId, + operation: `self-settle:${scope.threadId}`, + }), + }) + .pipe(Effect.mapError(threadManagementFailure)); + return { + status: "accepted", + threadId: scope.threadId, + runId: intent.runId, + clientRequestId: input.clientRequestId, + }; + }), scheduleTask: (scope, input) => Effect.gen(function* () { yield* requireCapability(scope); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 142e0cc55..f0bc1b5da 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -696,6 +696,18 @@ describe("orchestrator MCP toolkit", () => { const invoke = (name: string, args: Record) => invokeAs(invocation, name, args); + const deniedSettlement = yield* invoke("t3_thread_settle", { + clientRequestId: "wrong-credential", + }); + expect(deniedSettlement.structuredContent).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "orchestration_error", + message: expect.stringContaining("thread.metadata.update"), + }); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).thread.selfSettlement, + ).toBeUndefined(); + const pinned = yield* invoke("t3_thread_organize", { action: "pin" }); expect(pinned.structuredContent).toHaveProperty("sequence"); expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7dbbd21cb..046f4a2ee 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -6,6 +6,12 @@ import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const handlers = { + t3_thread_settle: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.settleThread(scope, input); + }), orchestrator_capabilities: () => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index 572cdcbf4..e67d39200 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -10,6 +10,18 @@ import { } from "./tools.ts"; describe("orchestrator MCP tool guidance", () => { + it("discovers self settlement with a required replay key and no target selector", () => { + const tool = OrchestratorToolkit.tools["t3_thread_settle"]; + assert.isDefined(tool); + const schema = Tool.getJsonSchema(tool) as { + readonly properties?: Readonly>; + readonly required?: ReadonlyArray; + }; + assert.deepEqual(Object.keys(schema.properties ?? {}), ["clientRequestId"]); + assert.include(schema.required ?? [], "clientRequestId"); + assert.include(tool.description ?? "", "checkpoint"); + }); + it("directs subagent requests to delegation instead of ordinary threads", () => { assert.include(DelegateTaskTool.description ?? "", "child agent/subagent"); assert.include(DelegateTaskTool.description ?? "", "cross-provider"); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index a86f1ce21..eb8997c60 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -14,6 +14,8 @@ import { OrchestratorMcpTaskCancelResult, OrchestratorMcpUpdateScheduledTaskInput, OrchestratorMcpTaskStatusInput, + OrchestratorMcpThreadSettleInput, + OrchestratorMcpThreadSettleResult, OrchestratorMcpThreadInterruptInput, OrchestratorMcpThreadInterruptResult, OrchestratorMcpThreadListInput, @@ -237,7 +239,21 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { .annotate(Tool.Title, "Interrupt a T3 thread") .annotate(Tool.Destructive, true); +const ThreadSettleTool = Tool.make("t3_thread_settle", { + description: + "Request settlement of THIS calling thread after this turn succeeds and its checkpoint completes. Returns an accepted deferred request; keep writing the current reply normally. This does not interrupt the turn. User follow-up work, interruption, failure, reverse state actions, or server restart cancel the request. Reuse the same clientRequestId when retrying; the receipt remains bound to the original run.", + parameters: OrchestratorMcpThreadSettleInput, + success: OrchestratorMcpThreadSettleResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Settle this T3 thread after the reply") + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true); + export const OrchestratorToolkit = Toolkit.make( + ThreadSettleTool, OrchestratorCapabilitiesTool, DelegateTaskTool, TaskStatusTool, diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 5f7db32b1..071ba7db0 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -100,6 +100,7 @@ function makeExecutorLayer(input: { Layer.succeed( ProviderSessionManager.ProviderSessionManagerV2, ProviderSessionManager.ProviderSessionManagerV2.of({ + isMcpCallerAttached: () => Effect.succeed(false), shutdown: Effect.void, open: () => Effect.die("unused open"), get: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0a9138318..91f4e86c7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,3 +1,9 @@ +import { + cancelsSelfSettlement, + selfSettlementRun, + selfSettlementTerminalDisposition, + type SelfSettlementIntent, +} from "./SelfSettlement.ts"; import { latestExecutedRun, latestRootProviderFailure, @@ -245,6 +251,12 @@ export interface OrchestratorV2DispatchResult { } export interface OrchestratorV2Shape { + readonly requestSelfSettlement: (input: { + readonly threadId: ThreadId; + readonly commandId: CommandId; + readonly mcpCredentialId: string; + readonly providerInstanceId: ProviderInstanceId; + }) => Effect.Effect; readonly resumeQueuedRuns: Effect.Effect; /** Startup pass that settles delegated-task results and deliveries runs left behind. */ readonly recoverDelegatedTasks: Effect.Effect; @@ -9741,6 +9753,34 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const plan = yield* dispatchOnce(command).pipe( + Effect.flatMap((planned) => + Effect.gen(function* () { + if (!cancelsSelfSettlement(command)) return planned; + const threadId = commandThreadId(command); + const current = yield* projectionStore + .getThread(threadId) + .pipe(mapDispatchError(command)); + if (current.selfSettlement == null) return planned; + const lastThreadEvent = planned.events.findLast( + (event) => event.threadId === threadId && "settledOverride" in event.payload, + ); + const thread = + lastThreadEvent !== undefined && "settledOverride" in lastThreadEvent.payload + ? lastThreadEvent.payload + : current; + const events = yield* Ref.make>([...planned.events]); + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { ...thread, selfSettlement: null }, + }); + return { ...planned, events: yield* Ref.get(events) }; + }), + ), Effect.flatMap((planned) => // A settle that finds the provider already ended everything has // nothing to record, which is its expected outcome, not a failure. @@ -9868,6 +9908,178 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchWithReceipt = (command: OrchestrationV2ServerCommand) => threadDispatch.withLock(commandThreadId(command), dispatchWithReceiptEffect(command)); + const requestSelfSettlement: OrchestratorV2Shape["requestSelfSettlement"] = (input) => + threadDispatch.withLock( + input.threadId, + Effect.gen(function* () { + const command = { + type: "thread.metadata.update" as const, + threadId: input.threadId, + commandId: input.commandId, + }; + const reject = (cause: string) => + new OrchestratorDispatchError({ ...command, commandType: command.type, cause }); + // Read the original durable acceptance before looking for a current run: credentials span turns. + const existingReceipt = yield* commandReceipts + .getByCommandId(input.commandId) + .pipe(mapDispatchError(command)); + if (Option.isSome(existingReceipt)) { + const receipt = existingReceipt.value; + if (receipt.threadId !== input.threadId || receipt.status === "rejected") + return yield* reject("Self-settlement request identity conflicts."); + const stored = yield* eventSink + .readByCommandId({ commandId: input.commandId }) + .pipe(Stream.runCollect, mapDispatchError(command)); + for (const entry of stored) { + if (entry.event.type !== "thread.metadata-updated") continue; + const intent = entry.event.payload.selfSettlement; + if ( + intent?.commandId === input.commandId && + intent.mcpCredentialId === input.mcpCredentialId && + intent.providerInstanceId === input.providerInstanceId + ) + return intent; + } + return yield* reject("The original command is not this self-settlement request."); + } + const projection = yield* projectionStore + .getThreadRecords(input.threadId, [ + "runs", + "messages", + "runtimeRequests", + "attempts", + "providerThreads", + ]) + .pipe(mapDispatchError(command)); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + const owner = + providerThread?.providerSessionId == null + ? null + : { + binding: { + providerSessionId: providerThread.providerSessionId, + instanceId: providerThread.providerInstanceId, + providerThreadId: providerThread.id, + }, + }; + const attached = + owner !== null && + (yield* providerSessions.isMcpCallerAttached({ + threadId: input.threadId, + providerSessionId: owner.binding.providerSessionId, + providerInstanceId: input.providerInstanceId, + mcpCredentialId: input.mcpCredentialId, + })); + const run = attached + ? selfSettlementRun( + projection, + { + providerSessionId: owner!.binding.providerSessionId, + providerInstanceId: input.providerInstanceId, + }, + owner!.binding, + ) + : undefined; + if (run === undefined) + return yield* reject( + "Self-settlement requires the calling provider's active run without queued or blocked user work.", + ); + if (projection.thread.selfSettlement != null) + return yield* reject( + "This turn already has an accepted self-settlement request; retry its original clientRequestId.", + ); + const intent: SelfSettlementIntent = { + commandId: input.commandId, + runId: run.id, + mcpCredentialId: input.mcpCredentialId, + providerSessionId: owner!.binding.providerSessionId, + providerInstanceId: input.providerInstanceId, + }; + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: input.threadId, + occurredAt: now, + payload: { ...projection.thread, selfSettlement: intent }, + }); + yield* eventSink + .commitCommand({ + ...command, + commandType: command.type, + acceptedAt: now, + events: yield* Ref.get(events), + effects: [], + }) + .pipe(mapDispatchError(command)); + return intent; + }), + ); + + const cancelSelfSettlement = (threadId: ThreadId) => + Effect.gen(function* () { + const thread = yield* projectionStore + .getThread(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (thread.selfSettlement == null) return; + const command = { + type: "thread.metadata.update" as const, + threadId, + commandId: CommandId.make(`${thread.selfSettlement.commandId}:cancel`), + }; + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { ...thread, selfSettlement: null }, + }); + yield* eventSink + .commitCommand({ + ...command, + commandType: command.type, + acceptedAt: now, + events: yield* Ref.get(events), + effects: [], + }) + .pipe(mapDispatchError(command)); + }); + + const consumeSelfSettlement = (threadId: ThreadId, runId: RunId) => + Effect.gen(function* () { + const projection = yield* projectionStore + .getThreadRecords(threadId, ["runs", "messages", "runtimeRequests", "attempts"]) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + const disposition = selfSettlementTerminalDisposition(projection, runId); + if (disposition === "ignore") return; + if (disposition === "cancel") return yield* cancelSelfSettlement(threadId); + yield* dispatchWithReceiptEffect({ + type: "thread.settle", + threadId, + commandId: CommandId.make(`${projection.thread.selfSettlement!.commandId}:settle`), + }).pipe( + Effect.catch((cause) => + Effect.gen(function* () { + yield* cancelSelfSettlement(threadId); + yield* Effect.logWarning("Self-settlement was cancelled at completion", { + threadId, + runId, + cause, + }); + }), + ), + ); + }); + const handleTerminalRun = (stored: OrchestrationV2StoredEvent) => Effect.gen(function* () { const threadId = stored.event.threadId; @@ -9889,12 +10101,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } yield* threadDispatch.withLock( threadId, - startNextQueuedRun( - threadId, - stored.event.type === "run.updated" && stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } - : undefined, - ), + Effect.gen(function* () { + if (stored.event.type === "run.updated") + yield* consumeSelfSettlement(threadId, stored.event.payload.id); + yield* startNextQueuedRun( + threadId, + stored.event.type === "run.updated" && stored.event.payload.status === "failed" + ? { failedRunId: stored.event.payload.id } + : undefined, + ); + }), ); }).pipe( Effect.catchCause((cause) => @@ -9906,6 +10122,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + // A restart cannot establish that a retained request's original provider turn succeeded. + for (const threadId of yield* projectionStore + .getRecoveryThreadIds("self-settlement") + .pipe(Effect.orDie)) { + yield* threadDispatch.withLock(threadId, cancelSelfSettlement(threadId)).pipe(Effect.orDie); + } + // Historical terminal events are already represented by the projections // below. Replaying the full event table on every server start delays live // queue promotion in proportion to the lifetime size of the database. @@ -10059,6 +10282,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); return OrchestratorV2.of({ + requestSelfSettlement, resumeQueuedRuns, recoverDelegatedTasks, recoverDelegatedTask, @@ -10181,6 +10405,14 @@ export const layer: Layer.Layer< const layerUnavailable: Layer.Layer = Layer.succeed( OrchestratorV2, OrchestratorV2.of({ + requestSelfSettlement: (input) => + Effect.fail( + new OrchestratorDispatchError({ + commandId: input.commandId, + commandType: "thread.metadata.update", + cause: "Orchestration V2 live runtime is not configured.", + }), + ), resumeQueuedRuns: Effect.fail( new OrchestratorDispatchError({ commandId: CommandId.make("command:system:resume-queued-runs"), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3bb7617ee..a01a756af 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -328,7 +328,50 @@ it.effect("memory recovery selection includes unfinished items from missing runs }).pipe(Effect.provide(ProjectionStore.layerMemory)), ); +const selfSettlementRecoveryRoundtrip = Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("self-settlement-recovery"); + const thread = yield* store.getThread(threadId); + const now = yield* DateTime.now; + const intent = { + mcpCredentialId: "synthetic-credential", + commandId: CommandId.make("synthetic-settlement-request"), + runId: RunId.make("synthetic-requesting-run"), + providerSessionId: ProviderSessionId.make("synthetic-session"), + providerInstanceId, + }; + assert.notInclude(yield* store.getRecoveryThreadIds("self-settlement"), threadId); + for (const type of ["thread.metadata-updated", "thread.settled", "thread.unsettled"] as const) { + yield* store.apply({ + id: EventId.make(`self-settlement-recovery:${type}`), + type, + threadId, + occurredAt: now, + payload: { ...thread, selfSettlement: intent, updatedAt: now }, + }); + assert.deepEqual((yield* store.getThreadProjection(threadId)).thread.selfSettlement, intent); + assert.include(yield* store.getRecoveryThreadIds("self-settlement"), threadId); + } + yield* store.apply({ + id: EventId.make("self-settlement-recovery:cancel"), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { ...thread, selfSettlement: null, updatedAt: now }, + }); + assert.isNull((yield* store.getThread(threadId)).selfSettlement); + assert.notInclude(yield* store.getRecoveryThreadIds("self-settlement"), threadId); +}); + +it.effect("memory projection retains self-settlement intent until cancellation", () => + selfSettlementRecoveryRoundtrip.pipe(Effect.provide(ProjectionStore.layerMemory)), +); + it.layer(TestLayer)("ProjectionStoreV2", (it) => { + it.effect( + "retains self-settlement intent until cancellation", + () => selfSettlementRecoveryRoundtrip, + ); it.effect( "keeps restart-cancelled work through a stale run.updated", () => restartCancelledWorkSurvivesStaleRunUpdate, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a6be30799..dfe3f3520 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -131,6 +131,7 @@ export const ProjectionStoreV2Error = Schema.Union([ export type ProjectionStoreV2Error = typeof ProjectionStoreV2Error.Type; export type ProjectionRecoveryKind = + | "self-settlement" | "queued-runs" | "runtime" | "subagent-results" @@ -495,6 +496,8 @@ function needsRecovery( ): boolean { if (projection.thread.deletedAt !== null) return false; switch (kind) { + case "self-settlement": + return projection.thread.selfSettlement != null; case "queued-runs": return ( projection.thread.archivedAt === null && @@ -3398,6 +3401,10 @@ export const layer: Layer.Layer = function* (kind: ProjectionRecoveryKind) { const candidates = (() => { switch (kind) { + case "self-settlement": + return sql`SELECT thread_id FROM orchestration_v2_projection_threads + WHERE CASE WHEN json_valid(payload_json) + THEN json_type(payload_json, '$.selfSettlement') = 'object' ELSE 0 END`; case "queued-runs": return sql` SELECT thread_id FROM orchestration_v2_projection_runs diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 696a40d8d..179af2069 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1088,7 +1088,40 @@ it.effect( new Set(["preview", "orchestration", "worktree", "pull-requests"]), ); + const binding = { + threadId, + providerSessionId, + providerInstanceId: modelSelection.instanceId, + mcpCredentialId: captured!.providerSessionId, + }; + assert.notEqual(binding.mcpCredentialId, providerSessionId); + assert.isTrue(yield* manager.isMcpCallerAttached(binding)); + assert.isFalse( + yield* manager.isMcpCallerAttached({ ...binding, mcpCredentialId: "wrong-credential" }), + ); + assert.isFalse( + yield* manager.isMcpCallerAttached({ + ...binding, + threadId: ThreadId.make("other-thread"), + }), + ); + assert.isFalse( + yield* manager.isMcpCallerAttached({ + ...binding, + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }), + }), + ); + assert.isFalse( + yield* manager.isMcpCallerAttached({ + ...binding, + providerInstanceId: ProviderInstanceId.make("other-provider"), + }), + ); yield* manager.close(providerSessionId); + assert.isFalse(yield* manager.isMcpCallerAttached(binding)); assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); assert.isUndefined(yield* registry.resolve(token!)); }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 20e240e79..5fb80c2b4 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -143,6 +143,13 @@ export const ProviderSessionManagerV2Error = Schema.Union([ export type ProviderSessionManagerV2Error = typeof ProviderSessionManagerV2Error.Type; export interface ProviderSessionManagerV2Shape { + readonly isMcpCallerAttached: (input: { + readonly threadId: ThreadId; + readonly providerSessionId: ProviderSessionId; + readonly providerInstanceId: ProviderInstanceId; + readonly mcpCredentialId: string; + }) => Effect.Effect; + readonly shutdown: Effect.Effect; readonly open: (input: { readonly threadId: ThreadId; @@ -1683,6 +1690,18 @@ export const layerWithOptions = ( yield* Effect.addFinalizer(() => shutdown); return ProviderSessionManagerV2.of({ + isMcpCallerAttached: (input) => + Ref.get(sessions).pipe( + Effect.map((entries) => { + const entry = entries.get(sessionKey(input.providerSessionId)); + return ( + entry !== undefined && + entry.runtime.instanceId === input.providerInstanceId && + entry.attachedThreadIds.has(input.threadId) && + entry.mcpCredentialIdByThread.get(input.threadId) === input.mcpCredentialId + ); + }), + ), shutdown, open: (input) => sessionOpen.withLock( diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 91fe20459..3b8c28be7 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -261,6 +261,7 @@ it.effect( const sessionManagerLayer = Layer.succeed( ProviderSessionManager.ProviderSessionManagerV2, ProviderSessionManager.ProviderSessionManagerV2.of({ + isMcpCallerAttached: () => Effect.succeed(false), shutdown: Effect.void, open: () => Effect.die("unused open"), get: (providerSessionId) => diff --git a/apps/server/src/orchestration-v2/SelfSettlement.test.ts b/apps/server/src/orchestration-v2/SelfSettlement.test.ts new file mode 100644 index 000000000..9e8f9fd1e --- /dev/null +++ b/apps/server/src/orchestration-v2/SelfSettlement.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + OrchestrationV2ThreadProjection, + OrchestrationV2ConversationMessage, + OrchestrationV2Command, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + cancelsSelfSettlement, + selfSettlementRun, + selfSettlementTerminalDisposition, +} from "./SelfSettlement.ts"; + +const caller = { + providerSessionId: "session-1", + providerInstanceId: ProviderInstanceId.make("codex"), +}; +const owner = { + providerSessionId: ProviderSessionId.make("session-1"), + instanceId: caller.providerInstanceId, + providerThreadId: ProviderThreadId.make("provider-thread-1"), +}; +const runId = RunId.make("run-1"); +const threadId = ThreadId.make("thread-1"); +const commandId = CommandId.make("self-settle-1"); +function fixture(): OrchestrationV2ThreadProjection { + const now = "2026-10-04T00:00:00.000Z"; + const selection = { instanceId: "codex", model: "test-model" }; + return Schema.decodeUnknownSync(Schema.toCodecJson(OrchestrationV2ThreadProjection))({ + thread: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: "project-1", + title: "Self settlement", + providerInstanceId: "codex", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: owner.providerThreadId, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + selfSettlement: { + mcpCredentialId: "credential-1", + commandId, + runId, + providerSessionId: owner.providerSessionId, + providerInstanceId: owner.instanceId, + }, + }, + runs: [ + { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: "codex", + modelSelection: selection, + providerThreadId: owner.providerThreadId, + userMessageId: "message-1", + rootNodeId: "node-1", + activeAttemptId: "attempt-1", + status: "running", + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + updatedAt: now, + attempts: [ + { + id: "attempt-1", + runId, + attemptOrdinal: 1, + rootNodeId: "node-1", + providerInstanceId: "codex", + providerThreadId: owner.providerThreadId, + providerTurnId: null, + reason: "initial", + status: "running", + startedAt: now, + completedAt: null, + }, + ], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + visibleTurnItems: [], + turnItems: [], + plans: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + }); +} + +describe("self settlement lifecycle decisions", () => { + it("accepts only the calling session's active run and leaves that run active", () => { + const projection = fixture(); + expect(selfSettlementRun(projection, caller, owner)?.id).toBe(runId); + expect(projection.runs[0]?.status).toBe("running"); + expect( + selfSettlementRun(projection, { ...caller, providerSessionId: "other" }, owner), + ).toBeUndefined(); + expect( + selfSettlementRun( + projection, + { ...caller, providerInstanceId: ProviderInstanceId.make("other") }, + owner, + ), + ).toBeUndefined(); + expect(selfSettlementRun(projection, caller, null)).toBeUndefined(); + expect(selfSettlementRun({ ...projection, attempts: [] }, caller, owner)).toBeUndefined(); + expect( + selfSettlementRun( + { + ...projection, + thread: { ...projection.thread, activeProviderThreadId: ProviderThreadId.make("other") }, + }, + caller, + owner, + ), + ).toBeUndefined(); + }); + it("rejects archived, deleted, inactive, and superseded caller runs", () => { + const projection = fixture(); + const now = projection.thread.updatedAt; + for (const thread of [ + { ...projection.thread, archivedAt: now }, + { ...projection.thread, deletedAt: now }, + ]) { + expect(selfSettlementRun({ ...projection, thread }, caller, owner)).toBeUndefined(); + expect( + selfSettlementTerminalDisposition( + { + ...projection, + thread, + runs: [{ ...projection.runs[0]!, status: "completed" }], + }, + runId, + ), + ).toBe("cancel"); + } + for (const status of [ + "preparing", + "queued", + "waiting", + "completed", + "failed", + "interrupted", + ] as const) { + expect( + selfSettlementRun( + { + ...projection, + runs: [{ ...projection.runs[0]!, status }], + }, + caller, + owner, + ), + ).toBeUndefined(); + } + expect( + selfSettlementRun( + { + ...projection, + attempts: [{ ...projection.attempts[0]!, status: "superseded" }], + }, + caller, + owner, + ), + ).toBeUndefined(); + }); + it("keeps intent while the reply and checkpoint are running and settles only completed", () => { + const projection = fixture(); + for (const status of ["running", "waiting", "completed"] as const) { + const current = { ...projection, runs: [{ ...projection.runs[0]!, status }] }; + expect(selfSettlementTerminalDisposition(current, runId)).toBe( + status === "completed" ? "settle" : "ignore", + ); + } + }); + it("cancels failed, interrupted, cancelled and rolled-back runs", () => { + const projection = fixture(); + for (const status of ["failed", "interrupted", "cancelled", "rolled_back"] as const) { + expect( + selfSettlementTerminalDisposition( + { ...projection, runs: [{ ...projection.runs[0]!, status }] }, + runId, + ), + ).toBe("cancel"); + } + }); + it("blocks queued and held user work while ignoring automatic queued wakeups", () => { + const projection = fixture(); + const queued = { + ...projection.runs[0]!, + id: RunId.make("run-2"), + ordinal: 2, + status: "queued" as const, + queueHeld: true, + }; + const queuedProjection = { ...projection, runs: [...projection.runs, queued] }; + expect(selfSettlementRun(queuedProjection, caller, owner)).toBeUndefined(); + expect(selfSettlementTerminalDisposition(queuedProjection, runId)).toBe("cancel"); + const notification = { + ...queuedProjection, + messages: [ + Schema.decodeUnknownSync(Schema.toCodecJson(OrchestrationV2ConversationMessage))({ + createdBy: "system", + creationSource: "server", + id: queued.userMessageId, + threadId, + runId: queued.id, + nodeId: null, + role: "user", + text: "Wake", + attachments: [], + streaming: false, + notification: { source: { kind: "monitor" }, outcome: "completed", summary: "Wake" }, + createdAt: "2026-10-04T00:00:00.000Z", + updatedAt: "2026-10-04T00:00:00.000Z", + }), + ], + }; + expect(selfSettlementRun(notification, caller, owner)?.id).toBe(runId); + }); + it("never settles a successor on delayed success of the original run", () => { + const projection = fixture(); + expect( + selfSettlementTerminalDisposition( + { + ...projection, + runs: [ + { ...projection.runs[0]!, status: "completed" }, + { + ...projection.runs[0]!, + id: RunId.make("successor"), + ordinal: 2, + status: "completed", + }, + ], + }, + runId, + ), + ).toBe("cancel"); + expect(selfSettlementTerminalDisposition(projection, RunId.make("older"))).toBe("ignore"); + expect( + selfSettlementTerminalDisposition( + { ...projection, thread: { ...projection.thread, selfSettlement: null } }, + runId, + ), + ).toBe("ignore"); + }); + it("invalidates new user work in every delivery mode and explicit reverse actions", () => { + for (const type of [ + "start_immediately", + "queue_after_active", + "defer_start", + "steer_active", + "restart_active", + ] as const) { + expect( + cancelsSelfSettlement({ + type: "message.dispatch", + commandId, + threadId, + messageId: MessageId.make("new-message"), + createdBy: "user", + creationSource: "web", + text: "Follow up", + attachments: [], + dispatchMode: { type, targetRunId: runId }, + }), + ).toBe(true); + } + for (const type of [ + "thread.unsettle", + "thread.pin", + "thread.unpin", + "thread.unsnooze", + "thread.archive", + "thread.unarchive", + ] as const) { + const command = Schema.decodeUnknownSync(OrchestrationV2Command)({ + type, + commandId, + threadId, + reason: "user", + }); + expect(cancelsSelfSettlement(command)).toBe(true); + } + expect( + cancelsSelfSettlement({ + type: "thread.metadata.update", + commandId, + threadId, + title: "Retitle", + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/orchestration-v2/SelfSettlement.ts b/apps/server/src/orchestration-v2/SelfSettlement.ts new file mode 100644 index 000000000..be59e8dd2 --- /dev/null +++ b/apps/server/src/orchestration-v2/SelfSettlement.ts @@ -0,0 +1,144 @@ +import type { + OrchestrationV2AppThread, + OrchestrationV2Run, + OrchestrationV2ServerCommand, + OrchestrationV2ThreadProjection, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + RunId, +} from "@t3tools/contracts"; + +type SettlementProjection = Pick< + OrchestrationV2ThreadProjection, + "thread" | "runs" | "messages" | "runtimeRequests" | "attempts" +>; +type Caller = { + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; +}; +type Owner = { + readonly providerSessionId: ProviderSessionId; + readonly instanceId: ProviderInstanceId; + readonly providerThreadId: ProviderThreadId; +}; + +function blocksSettlement(projection: SettlementProjection, exceptRunId: RunId): boolean { + const automatic = new Set( + projection.messages + .filter( + (message) => + message.notification !== undefined || message.delegatedCompletion !== undefined, + ) + .map((message) => message.id), + ); + return ( + projection.runs.some( + (run) => + run.id !== exceptRunId && + ["preparing", "starting", "running", "waiting", "queued"].includes(run.status) && + !(run.status === "queued" && automatic.has(run.userMessageId)), + ) || + projection.runtimeRequests.some( + (request) => + request.status === "pending" && + (request.kind !== "user_input" || request.responseCapability.type !== "message"), + ) + ); +} + +export function selfSettlementRun( + projection: SettlementProjection, + caller: Caller, + owner: Owner | null, +): OrchestrationV2Run | undefined { + const run = projection.runs + .toSorted((a, b) => b.ordinal - a.ordinal) + .find((candidate) => ["starting", "running"].includes(candidate.status)); + if ( + projection.thread.deletedAt !== null || + projection.thread.archivedAt !== null || + owner === null || + run === undefined || + run.activeAttemptId === null || + owner.providerSessionId !== caller.providerSessionId || + owner.instanceId !== caller.providerInstanceId || + owner.providerThreadId !== projection.thread.activeProviderThreadId || + run.providerThreadId !== owner.providerThreadId || + run.providerInstanceId !== caller.providerInstanceId || + !projection.attempts.some( + (attempt) => + attempt.id === run.activeAttemptId && + attempt.runId === run.id && + attempt.providerThreadId === owner.providerThreadId && + attempt.providerInstanceId === owner.instanceId && + (attempt.status === "pending" || attempt.status === "running"), + ) || + blocksSettlement(projection, run.id) + ) + return undefined; + return run; +} + +export function selfSettlementTerminalDisposition( + projection: SettlementProjection, + terminalRunId: RunId, +): "ignore" | "cancel" | "settle" { + const intent = projection.thread.selfSettlement; + if (intent == null) return "ignore"; + const run = projection.runs.find((candidate) => candidate.id === intent.runId); + if ( + run === undefined || + projection.runs.some( + (candidate) => + candidate.ordinal > run.ordinal && + !( + candidate.status === "queued" && + projection.messages.some( + (message) => + message.id === candidate.userMessageId && + (message.notification !== undefined || message.delegatedCompletion !== undefined), + ) + ), + ) + ) + return "cancel"; + if (terminalRunId !== intent.runId) return "ignore"; + if (["starting", "running", "waiting"].includes(run.status)) return "ignore"; + if ( + run.status !== "completed" || + projection.thread.deletedAt !== null || + projection.thread.archivedAt !== null || + blocksSettlement(projection, run.id) + ) + return "cancel"; + return "settle"; +} + +export function cancelsSelfSettlement(command: OrchestrationV2ServerCommand): boolean { + if (command.type === "message.dispatch") + return command.notification === undefined && command.delegatedCompletion === undefined; + return [ + "thread.settle", + "thread.unsettle", + "thread.pin", + "thread.unpin", + "thread.snooze", + "thread.unsnooze", + "thread.auto-settle.set", + "thread.archive", + "thread.unarchive", + "thread.delete", + "run.interrupt", + "checkpoint.rollback", + "queued-message.promote-to-steer", + "queued-run.edit", + "queue.resume", + "provider.switch", + "provider-session.detach", + "thread.model-selection.set", + "runtime-request.respond", + ].includes(command.type); +} + +export type SelfSettlementIntent = NonNullable; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f0468364a..6041b6514 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -271,6 +271,7 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; export interface ThreadManagementServiceShape { + readonly requestSelfSettlement: Orchestrator.OrchestratorV2["Service"]["requestSelfSettlement"]; readonly ensureLegacyTranscript: ( threadId: ThreadId, ) => Effect.Effect; @@ -722,6 +723,7 @@ const make = Effect.gen(function* () { }); return ThreadManagementService.of({ + requestSelfSettlement: orchestrator.requestSelfSettlement, ensureLegacyTranscript, dispatch, getTimelinePage: (threadId, options) => diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 84d6df901..36042afc5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -28,6 +28,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -427,7 +428,351 @@ const SharedApplicationDataPlaneTestLayer = Layer.mergeAll( Layer.provide(PlatformTestLayer), ); +const selfSettlementFixture = Effect.fnUntraced(function* (prefix: string) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const threadId = ThreadId.make(prefix); + const projectId = ProjectId.make(`${prefix}-project`); + yield* seedProject({ + projectId, + title: "Self settlement project", + workspaceRoot: process.cwd(), + defaultModelSelection: modelSelection, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${prefix}-create`), + threadId, + createdBy: "user", + creationSource: "web", + projectId, + title: "Self settlement", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${prefix}-start`), + threadId, + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`${prefix}-message`), + text: "Finish this task", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const initial = yield* orchestrator.getThreadProjection(threadId); + const run = initial.runs[0]!; + const provider = initial.providerThreads[0]!; + const callerSpy = vi + .spyOn(sessions, "isMcpCallerAttached") + .mockImplementation((input) => + Effect.succeed( + input.threadId === threadId && + input.mcpCredentialId === "self-credential" && + input.providerSessionId === provider.providerSessionId, + ), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + callerSpy.mockRestore(); + }), + ); + const request = { + threadId, + commandId: CommandId.make(`${prefix}-request`), + mcpCredentialId: "self-credential", + providerInstanceId: modelSelection.instanceId, + }; + const now = yield* DateTime.now; + const providerSession = { + id: provider.providerSessionId!, + driver, + providerInstanceId: modelSelection.instanceId, + status: "running" as const, + cwd: process.cwd(), + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + yield* sink.write({ + events: [ + { + id: EventId.make(`${prefix}-attached`), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: providerSession, + }, + ], + }); + return { orchestrator, sink, sessions, threadId, run, provider, providerSession, request }; +}); + it.layer(TestLayer)("OrchestrationV2LayerLive", (it) => { + it.effect( + "defers self settlement through checkpoint completion and replays the original run after successor work", + () => + Effect.gen(function* () { + const { orchestrator, sink, threadId, run, provider, providerSession, request } = + yield* selfSettlementFixture("runtime-self-settle"); + const accepted = yield* orchestrator.requestSelfSettlement(request); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + orchestrator.requestSelfSettlement({ + ...request, + mcpCredentialId: "another-credential", + }), + ), + ), + ); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + orchestrator.requestSelfSettlement({ + ...request, + providerInstanceId: alternateInstanceId, + }), + ), + ), + ); + assert.equal(accepted.runId, run.id); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs[0]!.status, + "starting", + ); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread.settledOverride, + null, + ); + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("self-waiting"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "waiting" }, + }, + ], + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread.selfSettlement?.runId, + run.id, + ); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread.settledOverride, + null, + ); + const settledEvents = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* sink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "thread.settled" + ? Queue.offer(settledEvents, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* sink.write({ + events: [ + { + id: EventId.make("self-completed"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); + yield* Queue.take(settledEvents); + const settled = yield* orchestrator.getThreadProjection(threadId); + assert.equal(settled.thread.settledOverride, "settled"); + assert.isNull(settled.thread.selfSettlement); + assert.isFalse( + settled.providerSessions.some((session) => session.id === provider.providerSessionId), + ); + const outbox = yield* EffectOutbox.EffectOutboxV2; + assert.deepEqual( + (yield* outbox.listByCommandId(CommandId.make(`${request.commandId}:settle`))).map( + (effect) => effect.request, + ), + [ + { + type: "provider-session.detach", + providerSessionId: providerSession.id, + detail: "Thread settled.", + }, + ], + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("self-successor"), + threadId, + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("self-successor-message"), + text: "New work", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + assert.deepEqual(yield* orchestrator.requestSelfSettlement(request), accepted); + const successor = yield* orchestrator.getThreadProjection(threadId); + assert.notEqual(successor.runs.at(-1)!.id, run.id); + assert.isNull(successor.thread.selfSettlement); + assert.equal(successor.thread.settledOverride, null); + }), + ); + + it.effect.each(["cancelled", "failed", "queue", "steer"] as const)( + "does not self settle after %s even if the original run later completes", + (invalidation) => + Effect.gen(function* () { + const prefix = `self-invalidate-${invalidation}`; + const { orchestrator, sink, sessions, threadId, run, provider, providerSession, request } = + yield* selfSettlementFixture(prefix); + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const now = yield* DateTime.now; + yield* orchestrator.requestSelfSettlement(request); + + const completeReaction = Effect.fnUntraced(function* ( + status: "cancelled" | "failed" | "completed", + ) { + const reacted = yield* Deferred.make(); + const withLock = executor.withLock; + // For this parentless thread, the terminal reactor first finalizes delivery, + // then consumes settlement and promotes the queue under a second lock. + let completedLocks = 0; + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => + withLock(key, effect).pipe( + Effect.tap(() => + key === threadId && ++completedLocks === 2 + ? Deferred.succeed(reacted, undefined) + : Effect.void, + ), + ); + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + yield* sink.write({ + events: [ + { + id: EventId.make(`${prefix}-${status}`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status, completedAt: now }, + }, + ], + }); + yield* Deferred.await(reacted); + }).pipe(Effect.ensuring(Effect.sync(() => lockSpy.mockRestore()))); + }); + + if (invalidation === "cancelled" || invalidation === "failed") { + yield* completeReaction(invalidation); + } else { + if (invalidation === "steer") { + yield* sink.write({ + events: [ + { + id: EventId.make(`${prefix}-running`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }, + { + id: EventId.make(`${prefix}-turn`), + type: "provider-turn.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make(`${prefix}-turn`), + providerThreadId: provider.id, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + ], + }); + const sessionSpy = vi + .spyOn(sessions, "get") + .mockReturnValue( + Effect.succeed(Option.some({ providerSession } as ProviderAdapterV2SessionRuntime)), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => sessionSpy.mockRestore())); + } + const commandId = CommandId.make(`${prefix}-new-work`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId, + threadId, + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`${prefix}-new-work`), + text: "Keep working", + attachments: [], + dispatchMode: { + type: invalidation === "queue" ? "queue_after_active" : "start_immediately", + }, + ...(invalidation === "steer" ? { deliveryIntent: "auto" as const } : {}), + }); + const updated = yield* orchestrator.getThreadProjection(threadId); + if (invalidation === "queue") { + assert.equal(updated.runs.at(-1)?.status, "queued"); + } else { + assert.lengthOf(updated.runs, 1); + assert.equal( + (yield* outbox.listByCommandId(commandId))[0]?.request.type, + "provider-turn.steer", + ); + } + } + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.selfSettlement); + yield* completeReaction("completed"); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(projection.thread.settledOverride); + assert.isNull(projection.thread.selfSettlement); + assert.deepEqual( + yield* outbox.listByCommandId(CommandId.make(`${request.commandId}:settle`)), + [], + ); + assert.lengthOf( + Array.from( + yield* sink + .readByCommandId({ + commandId: CommandId.make(`${request.commandId}:settle`), + }) + .pipe(Stream.runCollect), + ), + 0, + ); + }), + ); + it.effect("emits model updates separately from provider switches", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 4467fd976..9fb8d32a9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -190,6 +190,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( catchUp.shellSnapshotReads += 1; return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), + requestSelfSettlement: unused, ensureLegacyTranscript: unused, dispatch: unused, getTimelinePage: () => Effect.die("Unused timeline read"), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 220fec2b1..013fe5640 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -388,6 +388,17 @@ export const OrchestrationV2AppThread = Schema.Struct({ createdAt: Schema.DateTimeUtc, updatedAt: Schema.DateTimeUtc, archivedAt: Schema.NullOr(Schema.DateTimeUtc), + selfSettlement: Schema.optional( + Schema.NullOr( + Schema.Struct({ + mcpCredentialId: TrimmedNonEmptyString, + commandId: CommandId, + runId: RunId, + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + }), + ), + ), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 617442ece..f1f37b223 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { + OrchestratorMcpThreadSettleInput, + OrchestratorMcpThreadSettleResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, @@ -164,3 +166,30 @@ describe("orchestrator MCP contracts", () => { ).toBe("Loop converged."); }); }); + +describe("self settlement contracts", () => { + it("requires a stable request key and has no other-thread selector", () => { + const decode = Schema.decodeUnknownSync(OrchestratorMcpThreadSettleInput, { + onExcessProperty: "error", + }); + expect(decode({ clientRequestId: "finish-1" })).toEqual({ clientRequestId: "finish-1" }); + for (const input of [ + {}, + { clientRequestId: "" }, + { clientRequestId: "finish-1", threadId: "other" }, + ]) { + expect(() => decode(input)).toThrow(); + } + }); + it("reports accepted intent bound to the original run rather than completed settlement", () => { + const decode = Schema.decodeUnknownSync(OrchestratorMcpThreadSettleResult); + const receipt = { + status: "accepted", + threadId: "thread-1", + runId: "run-1", + clientRequestId: "finish-1", + }; + expect(decode(receipt)).toEqual(receipt); + expect(() => decode({ ...receipt, status: "settled" })).toThrow(); + }); +}); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 565f42866..c7d64e262 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -587,3 +587,16 @@ export class OrchestratorMcpFailure extends Schema.TaggedError Date: Mon, 5 Oct 2026 02:25:35 +0200 Subject: [PATCH 10/59] feat(mcp): restore persistent peer message blocking [L24] --- .../src/mcp/OrchestratorMcpService.test.ts | 71 +++ apps/server/src/mcp/OrchestratorMcpService.ts | 2 + .../src/mcp/ThreadMetadataMcpService.test.ts | 121 ++++- .../src/mcp/ThreadMetadataMcpService.ts | 15 + apps/server/src/mcp/threadAccess.ts | 16 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/mcp/toolkits/thread/handlers.test.ts | 183 +++++++ .../src/mcp/toolkits/thread/handlers.ts | 35 +- apps/server/src/mcp/toolkits/thread/tools.ts | 1 + .../DelegatedCompletionDelivery.test.ts | 505 ++++++++++-------- .../Orchestrator.control-reads.test.ts | 429 ++++++++++++++- .../src/orchestration-v2/Orchestrator.ts | 41 ++ .../src/orchestration-v2/ProjectionStore.ts | 2 + .../SubagentProjection.test.ts | 2 + .../orchestration-v2/SubagentProjection.ts | 1 + .../ThreadForkService.test.ts | 2 + .../src/orchestration-v2/ThreadForkService.ts | 1 + .../ThreadMessageIntake.test.ts | 51 ++ .../orchestration-v2/ThreadMessageIntake.ts | 1 + docs/user/thread-sidebar.md | 11 + .../contracts/src/orchestrationV2.test.ts | 5 + packages/contracts/src/orchestrationV2.ts | 5 + packages/contracts/src/orchestratorMcp.ts | 2 + .../contracts/src/threadMetadataMcp.test.ts | 6 + packages/contracts/src/threadMetadataMcp.ts | 7 +- 25 files changed, 1266 insertions(+), 251 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/thread/handlers.test.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index eae4ffa8c..7dccee956 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, + EventId, NodeId, ProjectId, ProviderDriverKind, @@ -16,6 +17,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import { emptyProjection } from "../orchestration-v2/ProjectionStore.ts"; +import { v2PullRequestThread } from "../orchestration-v2/testkit/pullRequestFixtures.ts"; import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -1137,3 +1140,71 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); }); + +it.effect( + "serializes historical and blocked flags through actual MCP thread list and detail methods", + () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex"); + const threadId = ThreadId.make("thread:mcp-block-serializer"); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-block-serializer"), + threadId, + providerSessionId: "session:mcp-block-serializer", + providerInstanceId, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + for (const blocked of [undefined, false, true]) { + const shell = { + ...v2PullRequestThread({ + id: threadId, + projectId: ProjectId.make("project:mcp-block-serializer"), + title: "Serializer fixture", + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestUserMessageAt: null, + createdAt: "2026-10-04T00:00:00Z", + updatedAt: "2026-10-04T00:00:00Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + }), + ...(blocked === undefined ? {} : { threadMessagesBlocked: blocked }), + }; + const projection = emptyProjection({ + type: "thread.created", + id: EventId.make("fixture:mcp-block-serializer"), + threadId, + occurredAt: shell.createdAt, + payload: { ...shell, lastVisitedAt: null }, + }); + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + listProjectThreads: () => Effect.succeed([shell]), + getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const listed = yield* service.listThreads(scope, {}); + const detail = yield* service.readThread(scope, { threadId }); + assert.equal(listed.threads[0]?.threadMessagesBlocked, blocked ?? false); + assert.equal(detail.thread.threadMessagesBlocked, blocked ?? false); + assert.equal(listed.threads[0]?.threadId, threadId); + assert.equal(detail.thread.threadId, threadId); + assert.equal(listed.threads[0]?.title, "Serializer fixture"); + assert.equal(detail.thread.title, "Serializer fixture"); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + } + }), +); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index cde5aa99b..14abd1a74 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -587,6 +587,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh model: shell.modelSelection.model, runtimeMode: shell.runtimeMode, interactionMode: shell.interactionMode, + threadMessagesBlocked: shell.threadMessagesBlocked ?? false, linkedPullRequest: shell.linkedPullRequest ?? null, ...threadSettlement(shell), parentThreadId: shell.lineage.parentThreadId, @@ -616,6 +617,7 @@ function threadDetail( model: projection.thread.modelSelection.model, runtimeMode: projection.thread.runtimeMode, interactionMode: projection.thread.interactionMode, + threadMessagesBlocked: projection.thread.threadMessagesBlocked ?? false, linkedPullRequest: projection.thread.linkedPullRequest ?? null, titleRegeneration: projection.thread.titleRegeneration === undefined || diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts index 2f6f8a8d2..439de351f 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts @@ -1,13 +1,24 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2AppThread, + type OrchestrationV2ServerCommand, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts"; +import { emptyProjection } from "../orchestration-v2/ProjectionStore.ts"; +import { v2PullRequestThread } from "../orchestration-v2/testkit/pullRequestFixtures.ts"; const threadId = ThreadId.make("thread:metadata-caller"); const scope: McpInvocationContext.McpInvocationScope = { @@ -74,3 +85,111 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () => expect(error.code).toBe("orchestration_error"); }), ); + +it.effect("blocks project peers, permits self recovery, and denies foreign unblocking", () => + Effect.gen(function* () { + const peerId = ThreadId.make("thread:metadata-peer"); + const makeThread = (id: ThreadId): OrchestrationV2AppThread => ({ + ...v2PullRequestThread({ + id, + projectId: ProjectId.make("project:metadata"), + title: "Metadata thread", + modelSelection: { instanceId: scope.providerInstanceId, model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestUserMessageAt: null, + createdAt: "2026-10-04T00:00:00Z", + updatedAt: "2026-10-04T00:00:00Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + }), + lastVisitedAt: null, + }); + const threads = new Map([ + [threadId, makeThread(threadId)], + [peerId, makeThread(peerId)], + ]); + const records = (id: ThreadId) => { + const thread = threads.get(id)!; + return emptyProjection({ + type: "thread.created", + id: EventId.make(`fixture:${id}`), + threadId: id, + occurredAt: thread.createdAt, + payload: thread, + }); + }; + const commands: OrchestrationV2ServerCommand[] = []; + const dependencies = Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + ...makeThread(threadId), + latestRunId: null, + activeRunId: null, + status: "idle" as const, + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + }), + getThreadRecords: (id) => Effect.succeed(records(id)), + getProjectThreadRecords: ({ threadId: id }) => Effect.succeed(records(id)), + dispatch: (command) => + Effect.gen(function* () { + if (command.type !== "thread.metadata.update") + return yield* Effect.die("unexpected metadata command"); + commands.push(command); + const current = threads.get(command.threadId)!; + const updated = { ...current, threadMessagesBlocked: command.threadMessagesBlocked }; + threads.set(command.threadId, updated); + return { + sequence: commands.length, + storedEvents: [ + { + sequence: commands.length, + commandId: command.commandId, + event: { + type: "thread.metadata-updated", + id: EventId.make(`metadata:${commands.length}`), + threadId: command.threadId, + occurredAt: DateTime.makeUnsafe("2026-10-04T00:00:00Z"), + payload: updated, + }, + }, + ], + }; + }), + }); + const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService.pipe( + Effect.provide( + ThreadMetadataMcp.layer.pipe(Layer.provide(dependencies), Layer.provide(NodeCrypto.layer)), + ), + ); + const peerBlock = yield* service.update(scope, { + threadId: peerId, + action: "block_thread_messages", + }); + expect(peerBlock).toMatchObject({ threadId: peerId, threadMessagesBlocked: true }); + expect(commands.at(-1)).toMatchObject({ + type: "thread.metadata.update", + threadId: peerId, + threadMessagesBlocked: true, + }); + const foreignAllow = yield* service + .update(scope, { threadId: peerId, action: "allow_thread_messages" }) + .pipe(Effect.flip); + expect(foreignAllow.code).toBe("capability_denied"); + expect(commands).toHaveLength(1); + const selfBlock = yield* service.update(scope, { action: "block_thread_messages" }); + expect(selfBlock.threadMessagesBlocked).toBe(true); + const selfAllow = yield* service.update(scope, { action: "allow_thread_messages" }); + expect(selfAllow.threadMessagesBlocked).toBe(false); + expect(threads.get(peerId)?.threadMessagesBlocked).toBe(true); + }), +); diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index d5f029626..44ac13530 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -73,6 +73,14 @@ function metadataCommand(input: { readonly update: ThreadMetadataMcpUpdateInput; }): Extract { switch (input.update.action) { + case "block_thread_messages": + case "allow_thread_messages": + return { + type: "thread.metadata.update", + commandId: input.commandId, + threadId: input.threadId, + threadMessagesBlocked: input.update.action === "block_thread_messages", + }; case "rename": return { type: "thread.metadata.update", @@ -119,6 +127,7 @@ function resultFromThread(input: { commandId: input.commandId, sequence: input.sequence, title: input.thread.title, + threadMessagesBlocked: input.thread.threadMessagesBlocked ?? false, titleRegeneration: input.thread.titleRegeneration === undefined || input.thread.titleRegeneration === null ? null @@ -176,6 +185,12 @@ const make = Effect.gen(function* () { : yield* threadManagement .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, []) .pipe(Effect.mapError(threadLookupFailure)); + if (input.action === "allow_thread_messages" && threadId !== scope.threadId) { + return yield* failure( + "capability_denied", + "Only the calling thread can allow incoming thread messages for itself.", + ); + } const requestKey = input.clientRequestId === undefined ? yield* crypto.randomUUIDv4.pipe(Effect.orDie) diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index bd3a4de60..1706e5ebf 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -101,6 +101,22 @@ export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* return context; }); +export const readMessageWritableThread = Effect.fn("mcp.readMessageWritableThread")(function* < + K extends ProjectionRecordField = never, +>(threadId?: ThreadId, fields: ReadonlyArray = []) { + const context = yield* readWritableThread(threadId, fields); + if ( + context.caller.id !== context.projection.thread.id && + context.projection.thread.threadMessagesBlocked === true + ) { + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: `Thread ${context.projection.thread.id} is blocking messages from other threads.`, + }); + } + return context; +}); + export const newCommandId = Effect.fn("mcp.newCommandId")(function* () { const crypto = yield* Crypto.Crypto; return CommandId.make(`mcp:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index eb8997c60..39da08160 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -189,7 +189,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { export const ThreadUpdateTool = Tool.make("t3_thread_update", { description: - "Update metadata for a thread in the calling project. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", + "Update metadata for a thread in the calling project. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Use block_thread_messages to reject new messages and merge-backs from other agents; allow_thread_messages is self-only. Owner input, self messages, task completion and already accepted work remain available. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", parameters: ThreadMetadataMcpUpdateInput, success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.test.ts b/apps/server/src/mcp/toolkits/thread/handlers.test.ts new file mode 100644 index 000000000..822cc9d54 --- /dev/null +++ b/apps/server/src/mcp/toolkits/thread/handlers.test.ts @@ -0,0 +1,183 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + EventId, + NodeId, + ProjectId, + ProviderInstanceId, + RunId, + RuntimeRequestId, + ThreadId, + TurnItemId, + type OrchestrationV2AppThread, + type OrchestrationV2ServerCommand, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; +import * as ScheduledTasks from "../../../scheduledTasks/ScheduledTaskService.ts"; +import { emptyProjection } from "../../../orchestration-v2/ProjectionStore.ts"; +import { v2PullRequestThread } from "../../../orchestration-v2/testkit/pullRequestFixtures.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ThreadToolkitHandlersLive } from "./handlers.ts"; +import { ThreadToolkit } from "./tools.ts"; + +it.effect("blocks foreign text edits and answers while preserving self controls and readback", () => + Effect.gen(function* () { + const callerId = ThreadId.make("thread:block-caller"); + const peerId = ThreadId.make("thread:block-peer"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const requestId = RuntimeRequestId.make("request:block-test"); + const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment:block-test"), + threadId: callerId, + providerSessionId: "session:block-test", + providerInstanceId, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const makeShell = (id: ThreadId) => ({ + ...v2PullRequestThread({ + id, + projectId: ProjectId.make("project:block-test"), + title: "Blocked thread", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestUserMessageAt: null, + createdAt: "2026-10-04T00:00:00Z", + updatedAt: "2026-10-04T00:00:00Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + }), + activeRunId: RunId.make(`run:${id}`), + threadMessagesBlocked: true, + }); + const shells = new Map([ + [callerId, makeShell(callerId)], + [peerId, makeShell(peerId)], + ]); + const records = (id: ThreadId) => { + const thread: OrchestrationV2AppThread = { ...shells.get(id)!, lastVisitedAt: null }; + const projection = emptyProjection({ + type: "thread.created", + id: EventId.make(`fixture:${id}`), + threadId: id, + occurredAt: thread.createdAt, + payload: thread, + }); + const now = DateTime.makeUnsafe("2026-10-04T00:00:00Z"); + return { + ...projection, + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make("node:question"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseCapability: { type: "message" as const }, + createdAt: now, + resolvedAt: null, + }, + ], + turnItems: [ + { + id: TurnItemId.make("item:question"), + type: "user_input_request" as const, + threadId: id, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "pending" as const, + title: null, + startedAt: null, + completedAt: null, + updatedAt: now, + requestId, + questions: [], + }, + ], + }; + }; + const commands: OrchestrationV2ServerCommand[] = []; + const dependencies = Layer.mergeAll( + NodeCrypto.layer, + Layer.mock(ThreadSearch.ThreadSearch)({}), + Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(shells.get(callerId)!), + getProjectThreadRecords: ({ threadId }) => Effect.succeed(records(threadId)), + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length, storedEvents: [] }; + }), + }), + ); + const toolkit = yield* ThreadToolkit.pipe( + Effect.provide(ThreadToolkitHandlersLive.pipe(Layer.provide(dependencies))), + ); + const invoke = ( + name: Name, + args: Parameters>[1], + ) => + toolkit.handle(name, args).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((results) => results.at(-1)!), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provide(dependencies), + ); + for (const [name, args] of [ + ["t3_queue_edit", { queuedRunId: RunId.make("run:queued"), text: "Foreign edit" }], + ["t3_pending_request_respond", { requestId, answers: { q: ["Foreign answer"] } }], + ] as const) { + const denied = yield* invoke(name, { threadId: peerId, ...args }); + expect(denied.isFailure).toBe(true); + expect(denied.result).toMatchObject({ code: "capability_denied" }); + } + expect(commands).toEqual([]); + const configuration = yield* invoke("t3_thread_configuration", { threadId: peerId }); + expect(configuration.result).toMatchObject({ threadMessagesBlocked: true }); + for (const threadId of [callerId, peerId]) { + if (threadId === peerId) + shells.set(peerId, { ...shells.get(peerId)!, threadMessagesBlocked: false }); + const edit = yield* invoke("t3_queue_edit", { + threadId, + queuedRunId: RunId.make("run:queued"), + text: "Allowed edit", + }); + expect(edit.isFailure).toBe(false); + expect(commands.at(-1)).toMatchObject({ + type: "queued-run.edit", + senderThreadId: callerId, + threadId, + }); + const answer = yield* invoke("t3_pending_request_respond", { + threadId, + requestId, + answers: { q: ["Allowed answer"] }, + }); + expect(answer.isFailure).toBe(false); + expect(commands.at(-1)).toMatchObject({ + type: "runtime-request.respond", + senderThreadId: callerId, + threadId, + }); + } + }), +); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index c0f5d135b..2341aeaf2 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -14,6 +14,7 @@ import { newCommandId, readCaller, readMutationCaller, + readMessageWritableThread, readThread, readWritableThread, unavailable, @@ -41,10 +42,18 @@ function queueEntry( const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* ( threadId: ThreadId | undefined, command: (common: { commandId: CommandId; threadId: ThreadId }) => OrchestrationV2Command, + contentMutation = false, ) { - const { threads, projection } = yield* readWritableThread(threadId); + const { threads, projection, caller } = yield* contentMutation + ? readMessageWritableThread(threadId) + : readWritableThread(threadId); + const requested = command({ commandId: yield* newCommandId(), threadId: projection.thread.id }); const result = yield* threads - .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id })) + .dispatch( + requested.type === "queued-run.edit" + ? { ...requested, senderThreadId: caller.id } + : requested, + ) .pipe(Effect.mapError(unavailable)); return { sequence: result.sequence }; }); @@ -57,7 +66,7 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( writable = false, ) { const context = yield* writable - ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"]) + ? readMessageWritableThread(input.threadId, ["runtimeRequests", "turnItems"]) : readThread(input.threadId, ["runtimeRequests", "turnItems"]); const request = context.projection.runtimeRequests.find( (request) => @@ -172,6 +181,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ modelSelection: thread.modelSelection, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, + threadMessagesBlocked: thread.threadMessagesBlocked ?? false, }; }), t3_thread_configure: (input) => @@ -207,7 +217,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ }), t3_pending_request_respond: (input) => Effect.gen(function* () { - const { threads, projection } = yield* readQuestion(input, true); + const { threads, projection, caller } = yield* readQuestion(input, true); const result = yield* threads .dispatch({ type: "runtime-request.respond", @@ -215,6 +225,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ commandId: yield* newCommandId(), requestId: input.requestId, answers: input.answers, + senderThreadId: caller.id, }) .pipe(Effect.mapError(unavailable)); return { sequence: result.sequence }; @@ -246,12 +257,16 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ ); }), t3_queue_edit: (input) => - dispatch(input.threadId, (common) => ({ - ...common, - type: "queued-run.edit", - runId: input.queuedRunId, - text: input.text, - })), + dispatch( + input.threadId, + (common) => ({ + ...common, + type: "queued-run.edit", + runId: input.queuedRunId, + text: input.text, + }), + true, + ), t3_queue_cancel: (input) => dispatch(input.threadId, (common) => ({ ...common, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 1517763b9..1b950fe02 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -180,6 +180,7 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.Boolean, }), }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index e11338775..a1e7a2d94 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -293,97 +293,114 @@ const seedParentWithTerminalTask = (input: { }); it.layer(TestLayer)("delegated completion delivery repairs", (it) => { - it.effect("acceptance batches pending siblings without acknowledging their results", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const sink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("mailbox-batch"); - const runId = RunId.make("mailbox-parent"); - const taskId = NodeId.make("mailbox-first"); - const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); - yield* seedParentWithTerminalTask({ - threadId, - runId, - projectId: ProjectId.make("mailbox-project"), - rootNodeId: NodeId.make("mailbox-root"), - taskId, - deliveryState: "claimed", - completionWake: "always", - deliveryTaskIds: [taskId], - now, - }); - const projection = yield* orchestrator.getThreadProjection(threadId); - const task = projection.subagents[0]!; - const pendingIds = [NodeId.make("mailbox-second"), NodeId.make("mailbox-third")]; - yield* sink.write({ - events: [ - { - id: EventId.make("mailbox-message"), - type: "message.updated", + it.effect.each([false, true])( + "acceptance batches pending siblings without acknowledging their results (parent blocked=%s)", + (blocked) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`mailbox-batch-${blocked}`); + const runId = RunId.make(`mailbox-parent-${blocked}`); + const taskId = NodeId.make(`mailbox-first-${blocked}`); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + yield* seedParentWithTerminalTask({ + threadId, + runId, + projectId: ProjectId.make(`mailbox-project-${blocked}`), + rootNodeId: NodeId.make(`mailbox-root-${blocked}`), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + if (blocked) + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`block-parent:${threadId}`), threadId, - runId, - occurredAt: now, - payload: { - id: messageId, + threadMessagesBlocked: true, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const task = projection.subagents[0]!; + const pendingIds = [ + NodeId.make(`mailbox-second-${blocked}`), + NodeId.make(`mailbox-third-${blocked}`), + ]; + yield* sink.write({ + events: [ + { + id: EventId.make(`mailbox-message-${blocked}`), + type: "message.updated", threadId, runId, - nodeId: task.parentNodeId, - role: "user", - text: "Background task finished", - attachments: [], - streaming: false, - createdBy: "agent", - creationSource: "server", - createdAt: now, - updatedAt: now, - delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, - }, - }, - ...pendingIds.map((id) => ({ - id: EventId.make(`event:${id}`), - type: "subagent.updated" as const, - threadId, - runId, - nodeId: id, - occurredAt: now, - payload: { - ...task, - id, - completionDelivery: { state: "pending" as const, observedByRunId: null }, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId, + nodeId: task.parentNodeId, + role: "user", + text: "Background task finished", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, }, - })), - ], - }); - yield* orchestrator.dispatch({ - type: "notification.delivery.accept", - commandId: CommandId.make("accept-first"), - threadId, - messageId, - }); - const accepted = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - accepted.subagents.find((row) => row.id === taskId)?.completionDelivery?.state, - "delivered", - ); - const cohort = accepted.runs.find((row) => row.id === runId)?.delegatedCompletion; - assert.deepEqual(cohort?.delivery?.taskIds, pendingIds); - assert.equal(cohort?.delivery?.generation, 2); - for (const id of pendingIds) { - assert.deepEqual(accepted.subagents.find((row) => row.id === id)?.completionDelivery, { - state: "claimed", - observedByRunId: null, + ...pendingIds.map((id) => ({ + id: EventId.make(`event:${id}`), + type: "subagent.updated" as const, + threadId, + runId, + nodeId: id, + occurredAt: now, + payload: { + ...task, + id, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + }, + })), + ], }); - } - yield* orchestrator.dispatch({ - type: "notification.delivery.accept", - commandId: CommandId.make("repeat-old-acceptance"), - threadId, - messageId, - }); - const duplicate = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(duplicate.runs.find((row) => row.id === runId)?.delegatedCompletion, cohort); - }), + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make(`accept-first-${blocked}`), + threadId, + messageId, + }); + const accepted = yield* orchestrator.getThreadProjection(threadId); + assert.equal(accepted.thread.threadMessagesBlocked ?? false, blocked); + assert.equal( + accepted.subagents.find((row) => row.id === taskId)?.completionDelivery?.state, + "delivered", + ); + const cohort = accepted.runs.find((row) => row.id === runId)?.delegatedCompletion; + assert.deepEqual(cohort?.delivery?.taskIds, pendingIds); + assert.equal(cohort?.delivery?.generation, 2); + for (const id of pendingIds) { + assert.deepEqual(accepted.subagents.find((row) => row.id === id)?.completionDelivery, { + state: "claimed", + observedByRunId: null, + }); + } + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make(`repeat-old-acceptance-${blocked}`), + threadId, + messageId, + }); + const duplicate = yield* orchestrator.getThreadProjection(threadId); + assert.equal(duplicate.thread.threadMessagesBlocked ?? false, blocked); + assert.deepEqual( + duplicate.runs.find((row) => row.id === runId)?.delegatedCompletion, + cohort, + ); + }), ); it.effect("acceptance batches a settled_only sibling once its spawning run ended", () => @@ -906,168 +923,182 @@ const seedRestartCancelledChild = (input: { }); it.layer(TestLayer)("delegated tasks across a server restart", (it) => { - it.effect("holds a restart-cancelled child for its continuation's result", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:restart-parent"); - const projectId = ProjectId.make("project:restart-parent"); - const runId = RunId.make("run:restart-parent"); - const rootNodeId = NodeId.make("node:restart-parent-root"); - yield* seedParentWithTerminalTask({ - threadId, - projectId, - runId, - rootNodeId, - taskId: NodeId.make("node:restart-parent-settled"), - deliveryState: "delivered", - now, - }); - const child = ( - name: string, - continuationPending: boolean, - runStatus?: "cancelled" | "completed", - ) => - seedRestartCancelledChild({ - parentThreadId: threadId, + it.effect.each([false, true])( + "holds a restart-cancelled child for its continuation's result (parent blocked=%s)", + (blocked) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread:restart-parent-${blocked}`); + const projectId = ProjectId.make(`project:restart-parent-${blocked}`); + const runId = RunId.make(`run:restart-parent-${blocked}`); + const rootNodeId = NodeId.make(`node:restart-parent-${blocked}-root`); + yield* seedParentWithTerminalTask({ + threadId, projectId, - parentRunId: runId, + runId, rootNodeId, - name, - completionWake: "always", - continuationPending, - ...(runStatus === undefined ? {} : { runStatus }), + taskId: NodeId.make(`node:restart-parent-${blocked}-settled`), + deliveryState: "delivered", now, }); - const resumed = yield* child("restart-resumed-child", true); - const stopped = yield* child("restart-stopped-child", false); - // Settled with only background work left: its interim reply is not the result. - const backgrounded = yield* child("restart-backgrounded-child", true, "completed"); - // A second restart cut the first continuation before it started. - const recut = yield* child("restart-recut-child", false); - const recutContinuationId = RunId.make("run:restart-recut-child:2"); - const recutCommandId = CommandId.make("command:restart-recut-child:reconcile"); - const recutRun = runEvent({ - threadId: recut.childThreadId, - runId: recutContinuationId, - ordinal: 2, - status: "cancelled", - now, - }); - yield* eventSink.writeWithEffects({ - commandId: recutCommandId, - events: [ - { - ...recutRun, - payload: { - ...recutRun.payload, - startedAt: null, - restartContinuationOfRunId: recut.childRunId, + if (blocked) + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`block-parent:${threadId}`), + threadId, + threadMessagesBlocked: true, + }); + const child = ( + name: string, + continuationPending: boolean, + runStatus?: "cancelled" | "completed", + ) => + seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name, + completionWake: "always", + continuationPending, + ...(runStatus === undefined ? {} : { runStatus }), + now, + }); + const resumed = yield* child(`restart-resumed-child-${blocked}`, true); + const stopped = yield* child(`restart-stopped-child-${blocked}`, false); + // Settled with only background work left: its interim reply is not the result. + const backgrounded = yield* child( + `restart-backgrounded-child-${blocked}`, + true, + "completed", + ); + // A second restart cut the first continuation before it started. + const recut = yield* child(`restart-recut-child-${blocked}`, false); + const recutContinuationId = RunId.make(`run:restart-recut-child-${blocked}:2`); + const recutCommandId = CommandId.make(`command:restart-recut-child-${blocked}:reconcile`); + const recutRun = runEvent({ + threadId: recut.childThreadId, + runId: recutContinuationId, + ordinal: 2, + status: "cancelled", + now, + }); + yield* eventSink.writeWithEffects({ + commandId: recutCommandId, + events: [ + { + ...recutRun, + payload: { + ...recutRun.payload, + startedAt: null, + restartContinuationOfRunId: recut.childRunId, + }, }, - }, - ], - effects: [ - { - id: `effect:restart-continuation:${recutContinuationId}`, - commandId: recutCommandId, - threadId: recut.childThreadId, - request: { type: "provider-runtime.continue", sourceRunId: recutContinuationId }, - }, - ], - }); + ], + effects: [ + { + id: `effect:restart-continuation:${recutContinuationId}`, + commandId: recutCommandId, + threadId: recut.childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: recutContinuationId }, + }, + ], + }); - yield* orchestrator.recoverDelegatedTasks; + yield* orchestrator.recoverDelegatedTasks; - const recovered = yield* orchestrator.getThreadProjection(threadId); - const task = (id: NodeId) => recovered.subagents.find((row) => row.id === id); - assert.equal(task(stopped.taskId)?.status, "cancelled"); - assert.equal(task(stopped.taskId)?.completionDelivery?.state, "claimed"); - assert.equal(task(resumed.taskId)?.status, "running"); - assert.isNull(task(resumed.taskId)?.result ?? null); - assert.equal(task(backgrounded.taskId)?.status, "running"); - assert.isNull(task(backgrounded.taskId)?.result ?? null); - assert.equal(task(recut.taskId)?.status, "running"); - assert.isTrue(yield* orchestrator.delegatedTaskResultPending(recut.childThreadId)); - assert.isTrue(yield* orchestrator.delegatedTaskResultPending(resumed.childThreadId)); - assert.isFalse(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); - // A replayed first continuation settling must not release the second one's hold. - yield* orchestrator.recoverDelegatedTask(recut.childThreadId, recut.childRunId); - const replayed = yield* orchestrator.getThreadProjection(threadId); - assert.equal(replayed.subagents.find((row) => row.id === recut.taskId)?.status, "running"); - // A caller that read the cancelled run before the child resumed sees it as pending. - yield* eventSink.write({ - commandId: CommandId.make("command:restart-stopped-child:resumed"), - events: [ - runEvent({ - threadId: stopped.childThreadId, - runId: RunId.make("run:restart-stopped-child:2"), - ordinal: 2, - status: "running", - now, - }), - ], - }); - assert.isTrue(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); - assert.isFalse( - recovered.contextTransfers.some( - (transfer) => transfer.sourceThreadId === resumed.childThreadId, - ), - ); + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = (id: NodeId) => recovered.subagents.find((row) => row.id === id); + assert.equal(task(stopped.taskId)?.status, "cancelled"); + assert.equal(task(stopped.taskId)?.completionDelivery?.state, "claimed"); + assert.equal(task(resumed.taskId)?.status, "running"); + assert.isNull(task(resumed.taskId)?.result ?? null); + assert.equal(task(backgrounded.taskId)?.status, "running"); + assert.isNull(task(backgrounded.taskId)?.result ?? null); + assert.equal(task(recut.taskId)?.status, "running"); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(recut.childThreadId)); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(resumed.childThreadId)); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + // A replayed first continuation settling must not release the second one's hold. + yield* orchestrator.recoverDelegatedTask(recut.childThreadId, recut.childRunId); + const replayed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(replayed.subagents.find((row) => row.id === recut.taskId)?.status, "running"); + // A caller that read the cancelled run before the child resumed sees it as pending. + yield* eventSink.write({ + commandId: CommandId.make(`command:restart-stopped-child-${blocked}:resumed`), + events: [ + runEvent({ + threadId: stopped.childThreadId, + runId: RunId.make(`run:restart-stopped-child-${blocked}:2`), + ordinal: 2, + status: "running", + now, + }), + ], + }); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + assert.isFalse( + recovered.contextTransfers.some( + (transfer) => transfer.sourceThreadId === resumed.childThreadId, + ), + ); - // The continuation's own run finishing settles the task with its result. - const afterSequence = yield* eventSink.latestSequence(); - const continuationRunId = RunId.make("run:restart-resumed-child:2"); - yield* eventSink.write({ - commandId: CommandId.make("command:restart-resumed-child:completed"), - events: [ - { - id: EventId.make("event:restart-resumed-child:result"), - type: "message.updated", - threadId: resumed.childThreadId, - runId: continuationRunId, - occurredAt: now, - payload: { - id: MessageId.make("message:restart-resumed-child:result"), + // The continuation's own run finishing settles the task with its result. + const afterSequence = yield* eventSink.latestSequence(); + const continuationRunId = RunId.make(`run:restart-resumed-child-${blocked}:2`); + yield* eventSink.write({ + commandId: CommandId.make(`command:restart-resumed-child-${blocked}:completed`), + events: [ + { + id: EventId.make(`event:restart-resumed-child-${blocked}:result`), + type: "message.updated", threadId: resumed.childThreadId, runId: continuationRunId, - nodeId: null, - role: "assistant", - text: "Finished after the restart.", - attachments: [], - streaming: false, - createdBy: "agent", - creationSource: "server", - createdAt: now, - updatedAt: now, + occurredAt: now, + payload: { + id: MessageId.make(`message:restart-resumed-child-${blocked}:result`), + threadId: resumed.childThreadId, + runId: continuationRunId, + nodeId: null, + role: "assistant", + text: "Finished after the restart.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, }, - }, - runEvent({ - threadId: resumed.childThreadId, - runId: continuationRunId, - ordinal: 2, - status: "completed", - now, - }), - ], - }); - const settled = yield* eventSink - .stream({ afterSequence, eventType: "subagent.updated" }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.id === resumed.taskId, - ), - Stream.take(1), - Stream.runHead, - ); - assert.isTrue(settled._tag === "Some"); - const finished = yield* orchestrator.getThreadProjection(threadId); - const finishedTask = finished.subagents.find((row) => row.id === resumed.taskId); - assert.equal(finishedTask?.status, "completed"); - assert.equal(finishedTask?.result, "Finished after the restart."); - }), + runEvent({ + threadId: resumed.childThreadId, + runId: continuationRunId, + ordinal: 2, + status: "completed", + now, + }), + ], + }); + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === resumed.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const finished = yield* orchestrator.getThreadProjection(threadId); + assert.equal(finished.thread.threadMessagesBlocked ?? false, blocked); + const finishedTask = finished.subagents.find((row) => row.id === resumed.taskId); + assert.equal(finishedTask?.status, "completed"); + assert.equal(finishedTask?.result, "Finished after the restart."); + }), ); it.effect("settles a restart-cancelled child whose continuation declines to start", () => 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 ad41ae6ff..8e5e2073f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import { CommandId, + EnvironmentId, MessageId, EventId, NodeId, @@ -9,6 +10,7 @@ import { ProviderDriverKind, ProviderInstanceId, ProviderSessionId, + type OrchestrationV2ServerCommand, ProviderThreadId, ProviderTurnId, RunAttemptId, @@ -17,12 +19,23 @@ import { ThreadId, TurnItemId, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { + SqlitePersistenceMemory, + makeSqlitePersistenceLive, +} from "../persistence/Layers/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as ThreadMetadataMcpService from "../mcp/ThreadMetadataMcpService.ts"; +import type { McpInvocationScope } from "../mcp/McpInvocationContext.ts"; +import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; @@ -534,3 +547,417 @@ it.effect("settles only the stopped run's background work, once", () => ]); }).pipe(Effect.provide(testLayer)), ); + +it.effect("persists message blocking and rejects foreign content before any effects", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:blocked-target"); + const senderThreadId = ThreadId.make("thread:blocked-sender"); + for (const id of [threadId, senderThreadId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${id}`), + threadId: id, + projectId: ProjectId.make("project:block-messages"), + title: "Message controls", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + } + assert.isFalse((yield* projections.getThread(threadId)).threadMessagesBlocked ?? false); + assert.isFalse((yield* projections.getThreadShell(threadId))?.threadMessagesBlocked); + const acceptedCommand = { + type: "message.dispatch", + commandId: CommandId.make("foreign:before-block"), + threadId, + senderThreadId, + messageId: MessageId.make("message:foreign-before-block"), + text: "Already accepted work", + attachments: [], + createdBy: "agent", + creationSource: "mcp", + dispatchMode: { type: "start_immediately" }, + } as const; + const accepted = yield* orchestrator.dispatch(acceptedCommand); + // Seed a retained intent on the active run to test cancellation at admission, + // independently of the MCP attachment checks covered by L23. + const currentThread = yield* projections.getThread(threadId); + const settlementIntent = { + commandId: CommandId.make("settle-intent:block-target"), + runId: (yield* projections.getThreadProjection(threadId)).runs[0]!.id, + mcpCredentialId: "credential:block-target", + providerSessionId: ProviderSessionId.make("session:block-target"), + providerInstanceId: instanceId, + }; + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + type: "thread.metadata-updated", + id: EventId.make("fixture:block-settlement-intent"), + threadId, + occurredAt: yield* DateTime.now, + payload: { ...currentThread, selfSettlement: settlementIntent }, + }, + ], + }); + const blocked = yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("block:target"), + threadId, + threadMessagesBlocked: true, + }); + assert.isTrue((yield* projections.getThread(threadId)).threadMessagesBlocked); + assert.isTrue((yield* projections.getThreadShell(threadId))?.threadMessagesBlocked); + assert.isTrue( + (yield* orchestrator.getShellSnapshot()).threads.find((t) => t.id === threadId) + ?.threadMessagesBlocked, + ); + const rows = yield* sql<{ + blocked: number; + }>`SELECT json_extract(payload_json, '$.threadMessagesBlocked') AS blocked + FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`; + assert.equal(rows[0]?.blocked, 1); + assert.deepEqual((yield* projections.getThread(threadId)).selfSettlement, settlementIntent); + const captureState = Effect.gen(function* () { + return { + target: yield* projections.getThreadProjection(threadId), + sender: yield* projections.getThreadProjection(senderThreadId), + events: yield* sql`SELECT * FROM orchestration_events ORDER BY sequence`, + receipts: yield* sql`SELECT * FROM orchestration_command_receipts ORDER BY command_id`, + outbox: yield* sql`SELECT * FROM orchestration_v2_effect_outbox`, + }; + }); + const beforeReplay = yield* captureState; + const replayed = yield* orchestrator.dispatch(acceptedCommand); + assert.equal(replayed.sequence, accepted.sequence); + assert.deepEqual(replayed.storedEvents, accepted.storedEvents); + assert.deepEqual(yield* captureState, beforeReplay); + const assertBlocked = (command: OrchestrationV2ServerCommand) => + Effect.gen(function* () { + const beforeDenial = yield* captureState; + const error = yield* orchestrator.dispatch(command).pipe(Effect.flip); + assert.equal(error._tag, "OrchestratorThreadMessagesBlockedError"); + assert.include(error.message, "blocking messages from other threads"); + assert.deepEqual(yield* captureState, beforeDenial); + const receipts = + yield* sql`SELECT * FROM orchestration_command_receipts WHERE command_id = ${command.commandId}`; + assert.lengthOf(receipts, 0); + }); + const before = yield* projections.getThreadProjection(threadId); + const effectsBefore = yield* sql`SELECT * FROM orchestration_v2_effect_outbox`; + const targetRunId = RunId.make("run:blocked-active"); + const modes = [ + { type: "start_immediately" }, + { type: "defer_start" }, + { type: "queue_after_active" }, + { type: "steer_active", targetRunId }, + { type: "restart_active", targetRunId }, + ] as const; + for (const dispatchMode of modes) { + yield* assertBlocked({ + type: "message.dispatch", + commandId: CommandId.make(`send:blocked:${dispatchMode.type}`), + threadId, + senderThreadId, + messageId: MessageId.make(`message:blocked:${dispatchMode.type}`), + text: "Foreign message", + attachments: [], + createdBy: "agent", + creationSource: "mcp", + dispatchMode, + }); + } + for (const command of [ + { type: "queued-run.edit", runId: targetRunId, text: "Foreign edit" }, + { + type: "runtime-request.respond", + requestId: RuntimeRequestId.make("request:blocked"), + answers: { q: ["Foreign answer"] }, + }, + ] as const) { + yield* assertBlocked({ + ...command, + commandId: CommandId.make(`blocked:${command.type}`), + threadId, + senderThreadId, + }); + } + const merge = { + type: "thread.merge_back", + sourceThreadId: senderThreadId, + targetThreadId: threadId, + sourcePoint: { type: "run", runId: targetRunId }, + creationSource: "mcp", + } as const; + yield* assertBlocked({ + ...merge, + commandId: CommandId.make("blocked:merge"), + createdBy: "agent", + }); + const ownerMerge = yield* orchestrator + .dispatch({ ...merge, commandId: CommandId.make("owner:merge"), createdBy: "user" }) + .pipe(Effect.flip); + // The normal lineage validation still runs for owner transfers. + assert.equal(ownerMerge._tag, "OrchestratorDispatchError"); + assert.deepEqual(yield* projections.getThreadProjection(threadId), before); + assert.deepEqual(yield* sql`SELECT * FROM orchestration_v2_effect_outbox`, effectsBefore); + const ownerCommand = { + type: "message.dispatch", + commandId: CommandId.make("owner:blocked-message"), + threadId, + messageId: MessageId.make("message:owner-blocked"), + text: "Owner input", + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "defer_start" }, + } as const; + yield* orchestrator.dispatch(ownerCommand); + assert.isNull((yield* projections.getThread(threadId)).selfSettlement); + yield* orchestrator.dispatch({ + ...ownerCommand, + commandId: CommandId.make("self:blocked-message"), + messageId: MessageId.make("message:self-blocked"), + createdBy: "agent", + creationSource: "mcp", + senderThreadId: threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("allow:target"), + threadId, + threadMessagesBlocked: false, + }); + assert.isFalse((yield* projections.getThreadShell(threadId))?.threadMessagesBlocked); + yield* orchestrator.dispatch({ + ...ownerCommand, + commandId: CommandId.make("foreign:allowed-message"), + messageId: MessageId.make("message:foreign-allowed"), + createdBy: "agent", + creationSource: "mcp", + senderThreadId, + }); + const after = yield* projections.getThreadProjection(threadId); + assert.lengthOf(after.messages, 4); + assert.lengthOf(after.runs, 4); + assert.isTrue( + blocked.storedEvents.some((stored) => stored.event.type === "thread.metadata-updated"), + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("keeps peer blocking and accepted receipts across a scoped SQLite close and reopen", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-peer-block-reopen-" }); + const dbPath = path.join(directory, "state.sqlite"); + const threadId = ThreadId.make("thread:peer-block-reopen"); + const senderThreadId = ThreadId.make("thread:peer-block-reopen-sender"); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:peer-block-reopen"), + threadId, + providerSessionId: "session:peer-block-reopen", + providerInstanceId: instanceId, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const makeFileRuntime = () => { + const database = makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer)); + const core = Layer.mergeAll( + database, + ProjectionStore.layer.pipe(Layer.provide(database)), + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "peer-block-reopen" }, + ProviderAdapterRegistry.makeLayer([adapter]), + { databaseLayer: database, runEffectWorker: false }, + ), + ); + const threads = ThreadManagement.layer.pipe(Layer.provideMerge(core)); + return Layer.mergeAll( + threads, + ThreadMetadataMcpService.layer.pipe( + Layer.provide(threads), + Layer.provide(NodeCrypto.layer), + ), + ); + }; + const acceptedCommand = { + type: "message.dispatch" as const, + commandId: CommandId.make("command:peer-block-reopen-accepted"), + threadId, + senderThreadId, + messageId: MessageId.make("message:peer-block-reopen-accepted"), + text: "Accepted before blocking", + attachments: [], + createdBy: "agent" as const, + creationSource: "mcp" as const, + dispatchMode: { type: "defer_start" as const }, + }; + const accepted = yield* Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + for (const id of [threadId, senderThreadId]) + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${id}`), + threadId: id, + projectId: ProjectId.make("project:peer-block-reopen"), + title: "Restart controls", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const accepted = yield* orchestrator.dispatch(acceptedCommand); + yield* (yield* ThreadMetadataMcpService.ThreadMetadataMcpService).update(scope, { + action: "block_thread_messages", + clientRequestId: "block-before-close", + }); + return accepted; + }).pipe(Effect.provide(makeFileRuntime())), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + assert.isTrue((yield* projections.getThread(threadId)).threadMessagesBlocked); + assert.isTrue((yield* projections.getThreadShell(threadId))?.threadMessagesBlocked); + assert.isTrue( + (yield* orchestrator.getShellSnapshot()).threads.find((row) => row.id === threadId) + ?.threadMessagesBlocked, + ); + const receiptBefore = + yield* sql`SELECT * FROM orchestration_command_receipts WHERE command_id = ${acceptedCommand.commandId}`; + assert.lengthOf(receiptBefore, 1); + const freshId = CommandId.make("command:peer-block-reopen-denied"); + const denied = yield* orchestrator + .dispatch({ + ...acceptedCommand, + commandId: freshId, + messageId: MessageId.make("message:peer-block-reopen-denied"), + }) + .pipe(Effect.flip); + assert.equal(denied._tag, "OrchestratorThreadMessagesBlockedError"); + assert.lengthOf( + yield* sql`SELECT * FROM orchestration_command_receipts WHERE command_id = ${freshId}`, + 0, + ); + const replayed = yield* orchestrator.dispatch(acceptedCommand); + assert.equal(replayed.sequence, accepted.sequence); + assert.deepEqual(replayed.storedEvents, accepted.storedEvents); + assert.deepEqual( + yield* sql`SELECT * FROM orchestration_command_receipts WHERE command_id = ${acceptedCommand.commandId}`, + receiptBefore, + ); + const allowed = yield* (yield* ThreadMetadataMcpService.ThreadMetadataMcpService).update( + scope, + { + action: "allow_thread_messages", + clientRequestId: "self-allow-after-reopen", + }, + ); + assert.isFalse(allowed.threadMessagesBlocked); + assert.isFalse((yield* projections.getThreadShell(threadId))?.threadMessagesBlocked); + }).pipe(Effect.provide(makeFileRuntime())), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + +it.effect( + "continues already accepted queued peer work after the recipient blocks new messages", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:blocked-accepted-queue"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create:blocked-accepted-queue"), + threadId, + projectId: ProjectId.make("project:blocked-accepted-queue"), + title: "Accepted queue", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const message = { + type: "message.dispatch" as const, + threadId, + text: "Accepted work", + attachments: [], + senderThreadId: ThreadId.make("thread:accepted-peer"), + createdBy: "agent" as const, + creationSource: "mcp" as const, + dispatchMode: { type: "start_immediately" as const }, + }; + yield* orchestrator.dispatch({ + ...message, + commandId: CommandId.make("accepted:active"), + messageId: MessageId.make("message:accepted-active"), + }); + const queuedCommand = { + ...message, + commandId: CommandId.make("accepted:queued"), + messageId: MessageId.make("message:accepted-queued"), + dispatchMode: { type: "queue_after_active" as const }, + }; + const accepted = yield* orchestrator.dispatch(queuedCommand); + const before = yield* orchestrator.getThreadProjection(threadId); + const queued = before.runs.find((run) => run.status === "queued")!; + assert.isDefined(queued); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("block:accepted-queue"), + threadId, + threadMessagesBlocked: true, + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("interrupt:accepted-active"), + threadId, + runId: before.runs[0]!.id, + holdQueue: true, + }); + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.runs.find((run) => run.id === queued.id)?.status, "queued"); + assert.isTrue(held.runs.find((run) => run.id === queued.id)?.queueHeld); + const effectsBefore = yield* sql`SELECT * FROM orchestration_v2_effect_outbox`; + const resume = { + type: "queue.resume" as const, + commandId: CommandId.make("resume:blocked-accepted-queue"), + threadId, + }; + yield* orchestrator.dispatch(resume); + const progressed = yield* orchestrator.getThreadProjection(threadId); + assert.isTrue(progressed.thread.threadMessagesBlocked); + assert.equal(progressed.runs.find((run) => run.id === queued.id)?.status, "starting"); + assert.equal( + progressed.messages.find((row) => row.id === queued.userMessageId)?.text, + "Accepted work", + ); + const effectsAfter = yield* sql`SELECT * FROM orchestration_v2_effect_outbox`; + assert.isAbove(effectsAfter.length, effectsBefore.length); + const replayed = yield* orchestrator.dispatch(queuedCommand); + assert.equal(replayed.sequence, accepted.sequence); + assert.deepEqual(replayed.storedEvents, accepted.storedEvents); + yield* orchestrator.dispatch(resume); + assert.deepEqual(yield* sql`SELECT * FROM orchestration_v2_effect_outbox`, effectsAfter); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 56137e040..60981e2e5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -184,6 +184,15 @@ export class OrchestratorProviderAdapterError extends Schema.TaggedError()( + "OrchestratorThreadMessagesBlockedError", + { commandId: CommandId, threadId: ThreadId }, +) { + override get message(): string { + return `Thread ${this.threadId} is blocking messages from other threads.`; + } +} + export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError()( "OrchestratorSubagentThreadReadOnlyError", { commandId: CommandId, threadId: ThreadId }, @@ -242,6 +251,7 @@ export const OrchestratorV2Error = Schema.Union([ OrchestratorCommandPreviouslyRejectedError, OrchestratorCommandIdConflictError, OrchestratorSubagentThreadReadOnlyError, + OrchestratorThreadMessagesBlockedError, ]); export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; @@ -2821,6 +2831,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, ...(command.title === undefined ? {} : { title: command.title }), + ...(command.threadMessagesBlocked === undefined + ? {} + : { threadMessagesBlocked: command.threadMessagesBlocked }), ...(command.limitRecovery === undefined ? {} : { limitRecovery }), ...(command.limitRecovery !== undefined && limitRecovery?.snooze === true && @@ -9756,6 +9769,34 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; } + const incomingTarget = + ((command.type === "message.dispatch" && command.createdBy !== "user") || + command.type === "queued-run.edit" || + command.type === "runtime-request.respond") && + command.senderThreadId !== undefined && + command.senderThreadId !== command.threadId + ? command.threadId + : command.type === "thread.merge_back" && + command.createdBy !== "user" && + command.sourceThreadId !== command.targetThreadId + ? command.targetThreadId + : undefined; + if (incomingTarget !== undefined) { + const target = yield* projectionStore + .getThread(incomingTarget) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: incomingTarget, cause }), + ), + ); + if (target.threadMessagesBlocked === true) { + return yield* new OrchestratorThreadMessagesBlockedError({ + commandId: command.commandId, + threadId: incomingTarget, + }); + } + } + const plan = yield* dispatchOnce(command).pipe( Effect.flatMap((planned) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index dfe3f3520..4b074b3a9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1420,6 +1420,7 @@ export function threadShellFromProjection( archivedAt: projection.thread.archivedAt, settledOverride: projection.thread.settledOverride, settledAt: projection.thread.settledAt, + threadMessagesBlocked: projection.thread.threadMessagesBlocked ?? false, unsettledAt: projection.thread.unsettledAt ?? null, snoozedUntil: projection.thread.snoozedUntil ?? null, snoozedAt: projection.thread.snoozedAt ?? null, @@ -1646,6 +1647,7 @@ function shellFromState(input: { archivedAt: input.state.thread.archivedAt, settledOverride: input.state.thread.settledOverride, settledAt: input.state.thread.settledAt, + threadMessagesBlocked: input.state.thread.threadMessagesBlocked ?? false, unsettledAt: input.state.thread.unsettledAt ?? null, snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 23d180102..41f4116ca 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -68,6 +68,7 @@ function makeParentThread(): OrchestrationV2AppThread { settledOverride: null, settledAt: null, lastVisitedAt: null, + threadMessagesBlocked: true, snoozedUntil, snoozedAt, deletedAt: null, @@ -92,6 +93,7 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { creationSource: "provider", }); + assert.isFalse(childThread.threadMessagesBlocked); assert.isNull(childThread.snoozedUntil); assert.isNull(childThread.snoozedAt); assert.equal(childThread.projectId, parentThread.projectId); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index fa912dfda..11ada697f 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -75,6 +75,7 @@ export function makeSubagentChildThread(input: { archivedAt: null, settledOverride: null, settledAt: null, + threadMessagesBlocked: false, snoozedUntil: null, snoozedAt: null, lastVisitedAt: null, diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index d9b38467d..05709efb7 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -56,6 +56,7 @@ function makeSourceThread(): OrchestrationV2AppThread { settledOverride: null, settledAt: null, lastVisitedAt: null, + threadMessagesBlocked: true, snoozedUntil, snoozedAt, deletedAt: null, @@ -145,6 +146,7 @@ it.effect("keeps a fork awake when its source thread is snoozed", () => const sourceRun = makeSourceRun("completed"); const result = yield* planFork(sourceRun); + assert.isFalse(result.targetThread.threadMessagesBlocked); assert.isNull(result.targetThread.snoozedUntil); assert.isNull(result.targetThread.snoozedAt); assert.equal(result.targetThread.projectId, sourceThread.projectId); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 56c7aa816..c83d10523 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -107,6 +107,7 @@ export const layer: Layer.Layer = Layer.succeed( archivedAt: null, settledOverride: null, settledAt: null, + threadMessagesBlocked: false, snoozedUntil: null, snoozedAt: null, lastVisitedAt: null, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index cd6c3c5f5..a4cc92f69 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -7,6 +7,7 @@ import { ChatAttachmentId, CommandId, EventId, + MessageId, RuntimeRequestId, ThreadId, TurnItemId, @@ -22,6 +23,7 @@ import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentS import * as ServerConfig from "../config.ts"; import { OrchestratorCommandPreviouslyRejectedError, + OrchestratorThreadMessagesBlockedError, OrchestratorDispatchError, } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; @@ -764,3 +766,52 @@ it.effect("applies the image budget across all questions before dispatch", () => expect(captured).toEqual([]); }).pipe(Effect.provide(intakeTestLayer)), ); + +it.effect( + "releases newly claimed message attachments when the target blocks foreign messages", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `${pendingId}.png`), + new Uint8Array([1, 2, 3]), + ); + const result = yield* dispatchCommand({ + type: "message.dispatch", + commandId: CommandId.make("message-blocked"), + threadId: ThreadId.make("thread-rejected"), + senderThreadId: ThreadId.make("thread-sender"), + messageId: MessageId.make("message-blocked"), + text: "Foreign attachment", + attachments: [ + { type: "image", id: pendingId, name: "screen.png", mimeType: "image/png", sizeBytes: 3 }, + ], + createdBy: "agent", + creationSource: "mcp", + dispatchMode: { type: "start_immediately" }, + }).pipe( + Effect.provide( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: (command) => + Effect.fail( + new OrchestratorThreadMessagesBlockedError({ + commandId: command.commandId, + threadId: ThreadId.make("thread-rejected"), + }), + ), + }), + ), + Effect.result, + ); + expect(result._tag).toBe("Failure"); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => + entry.startsWith("thread-rejected-"), + ), + ).toEqual([]); + expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe( + true, + ); + }).pipe(Effect.provide(intakeTestLayer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index b3c37e2fe..102ae4cfc 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -23,6 +23,7 @@ function dispatchWasNotAccepted( case "OrchestratorCommandPreviouslyRejectedError": case "OrchestratorCommandIdConflictError": case "OrchestratorSubagentThreadReadOnlyError": + case "OrchestratorThreadMessagesBlockedError": return true; default: return false; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb7c4e6a9..3f7b37cb5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -182,6 +182,17 @@ and copying a thread reference. A copied reference uses the thread's pull reques link when available, otherwise its thread ID. See [keybindings](./keybindings.md) for custom configuration. +## Block messages from other threads + +Ask the agent to block incoming messages for its thread, or for another thread +in the same project. The block stays in place across restarts. You can still +message the thread directly, and task-completion notifications and work already +accepted into its queue continue normally. + +To allow messages again, ask the agent in the blocked thread to unblock itself. +Agents in other threads cannot remove its block. New forks and subagent threads +start with their own unblocked setting. + ## Inspect agent work **Limited** means the provider stopped on a usage or rate limit. The conversation diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 601597fb5..e011d186a 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -1035,6 +1035,11 @@ describe("orchestration V2 contracts", () => { }); expect(shell.pendingBackgroundTasks).toEqual([]); + expect(shell.threadMessagesBlocked ?? false).toBe(false); + expect( + decodeOrchestrationV2ThreadShell({ ...shell, threadMessagesBlocked: true }) + .threadMessagesBlocked, + ).toBe(true); }); }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 013fe5640..fa6829dbd 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -363,6 +363,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Pull request the user linked to this thread (#8160); optional so @@ -1731,6 +1732,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Pull request the user linked to this thread (#8160). */ @@ -2617,6 +2619,7 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.metadata.update"), commandId: CommandId, threadId: ThreadId, + threadMessagesBlocked: Schema.optional(Schema.Boolean), title: Schema.optional(TrimmedNonEmptyString), /** Kick off (true) or abandon (false) an async title regeneration. */ regenerateTitle: Schema.optional(Schema.Boolean), @@ -2823,6 +2826,7 @@ export const OrchestrationV2Command = Schema.Union([ context: Schema.optional(OrchestrationMessageContext), commandId: CommandId, threadId: ThreadId, + senderThreadId: Schema.optional(ThreadId), runId: RunId, text: Schema.String, // Full replacement list. Absent = leave the message's attachments as-is, @@ -2833,6 +2837,7 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("runtime-request.respond"), commandId: CommandId, threadId: ThreadId, + senderThreadId: Schema.optional(ThreadId), requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), answers: Schema.optional(ProviderUserInputAnswers), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index c7d64e262..f698a6ce4 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -303,6 +303,7 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ model: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), @@ -348,6 +349,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ model: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), branch: Schema.NullOr(Schema.String), diff --git a/packages/contracts/src/threadMetadataMcp.test.ts b/packages/contracts/src/threadMetadataMcp.test.ts index e07477cf9..7fc6cb5ea 100644 --- a/packages/contracts/src/threadMetadataMcp.test.ts +++ b/packages/contracts/src/threadMetadataMcp.test.ts @@ -32,12 +32,18 @@ describe("ThreadMetadataMcpUpdateInput", () => { }, }, ); + for (const action of ["block_thread_messages", "allow_thread_messages"] as const) { + assert.deepEqual(decodeUpdate({ action }), { action }); + } assert.deepEqual(decodeUpdate({ action: "unlink_pull_request" }), { action: "unlink_pull_request", }); }); it("rejects missing action data and fields from another action", () => { + for (const action of ["block_thread_messages", "allow_thread_messages"] as const) { + assert.throws(() => decodeUpdate({ action, title: "Not allowed" })); + } assert.throws(() => decodeUpdate({ action: "rename" })); assert.throws(() => decodeUpdate({ action: "regenerate_title", title: "Not allowed" })); assert.throws(() => decodeUpdate({ action: "link_pull_request" })); diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index ca15a524d..75d351ee8 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -63,9 +63,11 @@ export const ThreadMetadataMcpAction = Schema.Literals([ "regenerate_title", "link_pull_request", "unlink_pull_request", + "block_thread_messages", + "allow_thread_messages", ]).annotate({ description: - "Metadata mutation: rename, regenerate_title, link_pull_request, or unlink_pull_request.", + "Metadata mutation: rename, regenerate_title, link_pull_request, unlink_pull_request, block_thread_messages, or allow_thread_messages. Only the calling thread can allow its messages again.", }); export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; @@ -101,6 +103,8 @@ export const ThreadMetadataMcpUpdateInput = Schema.Struct({ : "link_pull_request requires pullRequest and does not accept title."; case "regenerate_title": case "unlink_pull_request": + case "block_thread_messages": + case "allow_thread_messages": return input.title === undefined && input.pullRequest === undefined ? true : `${input.action} does not accept title or pullRequest.`; @@ -114,6 +118,7 @@ export const ThreadMetadataMcpUpdateResult = Schema.Struct({ action: ThreadMetadataMcpAction, commandId: CommandId, sequence: NonNegativeInt, + threadMessagesBlocked: Schema.optional(Schema.Boolean), title: Schema.String, titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), From 520984372c1c3905036eccdd11c8f29ed58cd9ef Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 02:37:51 +0200 Subject: [PATCH 11/59] fix(mcp): register deferred settlement presentation --- .../client-runtime/src/t3ToolSummary.test.ts | 58 +++++++++++++++++++ packages/client-runtime/src/t3ToolSummary.ts | 7 +++ .../shared/src/t3McpToolPresentation.test.ts | 31 +++++++++- packages/shared/src/t3McpToolPresentation.ts | 5 ++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 8ae001ea7..71614ba73 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -7,6 +7,64 @@ function completed(input: unknown, output?: unknown): T3ToolSummaryCall { } describe("summarizeT3ToolCalls", () => { + it("counts accepted self-settlement targets across replay without claiming settlement completed", () => { + const accepted = completed( + { clientRequestId: "request-1" }, + { status: "accepted", threadId: "thread-1", runId: "run-1", clientRequestId: "request-1" }, + ); + expect(summarizeT3ToolCalls("thread-settle", [accepted, accepted])).toEqual({ + label: "Requested settlement for 1 thread", + failedCount: 0, + }); + const second = completed( + { clientRequestId: "request-2" }, + { status: "accepted", threadId: "thread-2", runId: "run-2", clientRequestId: "request-2" }, + ); + expect(summarizeT3ToolCalls("thread-settle", [accepted, accepted, second])).toEqual({ + label: "Requested settlement for 2 threads", + failedCount: 0, + }); + }); + + it("keeps denied and unfinished self-settlement calls distinct from accepted requests", () => { + const denied = completed( + { clientRequestId: "denied-request" }, + { + structuredContent: { + _tag: "OrchestratorMcpFailure", + code: "capability_denied", + message: "Unavailable", + }, + }, + ); + const unfinished: T3ToolSummaryCall = { + input: { clientRequestId: "unfinished-request" }, + output: undefined, + outcome: "unfinished", + }; + expect(summarizeT3ToolCalls("thread-settle", [denied])).toEqual({ + label: "Tried to request settlement for 1 thread", + failedCount: 1, + }); + expect(summarizeT3ToolCalls("thread-settle", [unfinished])).toEqual({ + label: "Tried to request settlement for 1 thread", + failedCount: 0, + }); + const accepted = completed( + { clientRequestId: "accepted-request" }, + { + status: "accepted", + threadId: "thread-1", + runId: "run-1", + clientRequestId: "accepted-request", + }, + ); + expect(summarizeT3ToolCalls("thread-settle", [denied, unfinished, accepted])).toEqual({ + label: "Requested settlement for 1 thread", + failedCount: 1, + }); + }); + it("counts registered projects, repository destinations, and accepted thread launches", () => { expect( summarizeT3ToolCalls("project-create", [ diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index c6b40e5a6..a6176f680 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -168,6 +168,13 @@ export function summarizeT3ToolCalls( quantity(countEntities(threadIds), "thread"), ); break; + case "thread-settle": + label = phrase( + "Requested settlement for", + "request settlement for", + quantity(countEntities(threadIds), "thread"), + ); + break; case "task-status": label = phrase("Checked", "check", `task status ${times}`); break; diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 43b2d9c2d..8bc452029 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -1,8 +1,37 @@ import { describe, expect, it } from "vite-plus/test"; -import { T3_MCP_TOOL_NAMES, resolveT3McpToolPresentation } from "./t3McpToolPresentation.ts"; +import { + T3_MCP_TOOL_NAMES, + resolveT3McpToolDefinition, + resolveT3McpToolPresentation, + resolveT3McpToolSummaryAction, +} from "./t3McpToolPresentation.ts"; describe("resolveT3McpToolPresentation", () => { + it("presents deferred self-settlement as a request across provider name forms", () => { + expect(T3_MCP_TOOL_NAMES.has("t3_thread_settle")).toBe(true); + for (const prefix of ["", "mcp__t3-code__", "T3-code."]) { + const name = `${prefix}t3_thread_settle`; + expect(resolveT3McpToolDefinition(name)).toEqual({ + displayName: "Request settlement of this thread", + labels: [ + "Request settlement of", + "Requesting settlement of", + "Requested settlement of", + "this thread", + ], + icon: "t3-code", + summaryAction: "thread-settle", + }); + expect(resolveT3McpToolPresentation(name)).toEqual({ + displayName: "Request settlement of this thread", + logo: "t3-code", + }); + expect(resolveT3McpToolSummaryAction(name)).toBe("thread-settle"); + } + expect(resolveT3McpToolDefinition("mcp__another-server__t3_thread_settle")).toBeNull(); + }); + it("recognizes every T3 tool across provider prefixes and completion suffixes", () => { for (const tool of T3_MCP_TOOL_NAMES) { const presentation = resolveT3McpToolPresentation(tool); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index d85104cf4..be71bb213 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -21,6 +21,7 @@ export type T3McpToolSummaryAction = | "thread-send" | "thread-wait" | "thread-interrupt" + | "thread-settle" | "thread-configuration" | "thread-configure" | "thread-fork" @@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], "thread-interrupt", ), + t3_thread_settle: tool( + ["Request settlement of", "Requesting settlement of", "Requested settlement of", "this thread"], + "thread-settle", + ), t3_worktree_handoff: tool( ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], "worktree-handoff", From a33161e2b9ac64817547dd7b3335d6af5941dd5f Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 04:10:54 +0200 Subject: [PATCH 12/59] feat(desktop): support explicit isolated client profiles --- .../scripts/main-process-bundle.test.mjs | 54 ++++++++++- .../src/app/DesktopAppIdentity.test.ts | 38 ++++++++ apps/desktop/src/app/DesktopClerk.test.ts | 24 +++++ apps/desktop/src/app/DesktopConfig.ts | 1 + apps/desktop/src/app/DesktopEnvironment.ts | 2 + apps/desktop/src/app/DesktopUserData.ts | 10 +- .../src/app/DesktopUserDataOverride.test.ts | 95 +++++++++++++++++++ .../src/app/DesktopUserDataOverride.ts | 32 +++++++ apps/desktop/src/boot.ts | 15 +++ 9 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/DesktopUserDataOverride.test.ts create mode 100644 apps/desktop/src/app/DesktopUserDataOverride.ts diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 678b62f58..4b9da5219 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -114,9 +114,10 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); try { const entries = ["src/boot.ts", "src/compileCache.ts"]; - await NodeFSP.mkdir(NodePath.join(directory, "src")); + const sources = [...entries, "src/app/DesktopUserDataOverride.ts"]; + await NodeFSP.mkdir(NodePath.join(directory, "src/app"), { recursive: true }); await Promise.all( - entries.map((entry) => + sources.map((entry) => NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), ), ); @@ -135,6 +136,54 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = }); } const outputDirectory = NodePath.join(directory, "dist-electron"); + const emittedSources = new Map(await Promise.all( + (await NodeFSP.readdir(outputDirectory)).filter((name) => name.endsWith(".cjs")) + .map(async (name) => [name, await NodeFSP.readFile(NodePath.join(outputDirectory, name), "utf8")]), + )); + const runBoot = (override) => { + const operations = []; + const modules = new Map(); + const load = (name) => { + if (modules.has(name)) return modules.get(name).exports; + const module = { exports: {} }; + modules.set(name, module); + const source = emittedSources.get(name); + assert.ok(source, `Missing packaged bootstrap dependency: ${name}`); + NodeVM.runInNewContext(source, { + module, exports: module.exports, + process: { env: { T3CODE_DESKTOP_USER_DATA_DIR: override } }, + require: (specifier) => { + if (specifier === "node:path") return NodePath.posix; + if (specifier === "node:fs") return { + mkdirSync: (path, options) => { + assert.equal(options.recursive, true); + operations.push(`mkdir:${path}`); + }, + }; + if (specifier === "electron") return { + app: { setPath: (role, path) => operations.push(`${role}:${path}`) }, + }; + if (specifier === "./compileCache.cjs") { operations.push("cache"); return {}; } + if (specifier === "./main.cjs") { operations.push("startup"); return {}; } + return load(NodePath.posix.basename(specifier)); + }, + }); + return module.exports; + }; + return { operations, load: () => load("boot.cjs") }; + }; + const isolated = runBoot(" /isolated/other/../profile "); + isolated.load(); + assert.deepEqual(isolated.operations, [ + "mkdir:/isolated/profile", "userData:/isolated/profile", "sessionData:/isolated/profile", + "cache", "startup", + ]); + const defaults = runBoot(undefined); + defaults.load(); + assert.deepEqual(defaults.operations, ["cache", "startup"]); + const invalid = runBoot("relative/profile"); + assert.throws(invalid.load, /must be an absolute path/); + assert.deepEqual(invalid.operations, []); const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`; await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture); await NodeFSP.writeFile( @@ -154,6 +203,7 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = encoding: "utf8", env: { ...process.env, + T3CODE_DESKTOP_USER_DATA_DIR: undefined, APPIMAGE: "", NODE_COMPILE_CACHE: undefined, NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 2afa963fa..8f5eb4e84 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -147,6 +147,44 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { + it.effect("uses an explicit client profile independently of the server home", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + assert.equal(yield* identity.resolveUserDataPath, "/isolated/client-profile"); + assert.equal(environment.baseDir, "/isolated/server-home"); + }), + { + legacyPathExists: true, + environment: { + env: { + T3CODE_HOME: "/isolated/server-home", + T3CODE_DESKTOP_USER_DATA_DIR: " /isolated/other/../client-profile ", + }, + }, + }, + ), + ); + + it.effect("never probes the legacy profile for an explicit development profile", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + assert.equal(yield* identity.resolveUserDataPath, "/isolated/client-profile"); + }), + { + legacyPathProbeError: PlatformError.systemError({ + _tag: "PermissionDenied", module: "FileSystem", method: "exists", + pathOrDescriptor: "/legacy", description: "must not read legacy profile", + }), + environment: { env: { + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_DESKTOP_USER_DATA_DIR: "/isolated/client-profile", + } }, + }, + ), + ); it.effect("isolates the V2 profile even when the legacy V1 profile exists", () => withIdentity( Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index b629301b1..5845a09c3 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -50,12 +50,14 @@ const makeDesktopClerkLayer = ( openSystemSettings: () => Effect.succeed(false), copyText: () => Effect.void, }, + userDataDirectoryOverride: Option.Option = Option.none(), ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", platform, + userDataDirectoryOverride, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); const electronApp = { @@ -152,6 +154,28 @@ describe("DesktopClerk", () => { }, ); + it.effect("binds an explicit profile before Clerk without inspecting or copying default Windows state", () => { + const events: string[] = []; + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockImplementation(() => { + events.push("createClerkBridge"); + return { cleanup: vi.fn(), isPrimaryInstance: true }; + }); + const noProfileAccess = FileSystem.layerNoop({ + exists: () => Effect.die("must not inspect a default profile"), + readFileString: () => Effect.die("must not copy Windows Local State"), + makeDirectory: () => Effect.die("boot owns profile directory creation"), + writeFileString: () => Effect.die("must not migrate default profile state"), + }); + return Effect.gen(function* () { + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer( + false, events, "win32", noProfileAccess, undefined, + Option.some("/isolated/client-profile"), + ))); + assert.deepEqual(events, ["setPath:userData:/isolated/client-profile", "createClerkBridge"]); + }); + }); + it.effect("preserves bridge initialization failures", () => { const cause = new Error("bridge initialization failed"); storageMock.mockReturnValue(storageAdapter); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 773ea2450..ec59861bc 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -38,6 +38,7 @@ export const DesktopConfig = Config.all({ xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), xdgDataHome: trimmedString("XDG_DATA_HOME"), t3Home: trimmedString("T3CODE_HOME"), + userDataDirectory: trimmedString("T3CODE_DESKTOP_USER_DATA_DIR"), devServerUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option), appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 6a8bc8e0e..5f4d64c90 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -44,6 +44,7 @@ export class DesktopEnvironment extends Context.Service< readonly resourcesPath: string; readonly homeDirectory: string; readonly appDataDirectory: string; + readonly userDataDirectoryOverride: Option.Option; readonly baseDir: string; readonly stateDir: string; readonly desktopSettingsPath: string; @@ -204,6 +205,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( resourcesPath, homeDirectory, appDataDirectory, + userDataDirectoryOverride: config.userDataDirectory, baseDir, stateDir, desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index 3306b244d..31d46e67e 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import { resolveDesktopUserDataOverride } from "./DesktopUserDataOverride.ts"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; @@ -37,9 +39,15 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa readonly appDataDirectory: string; readonly isDevelopment: boolean; readonly platform: NodeJS.Platform; + readonly userDataDirectoryOverride?: Option.Option; }) { - const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const override = resolveDesktopUserDataOverride( + Option.getOrUndefined(input.userDataDirectoryOverride ?? Option.none()), + path, + ); + if (override !== null) return override; + const fs = yield* FileSystem.FileSystem; const names = input.isDevelopment ? { current: "t3code-dev", legacy: "T3 Code (Dev)" } : { current: "t3code-v2", legacy: "T3 Code (Alpha)" }; diff --git a/apps/desktop/src/app/DesktopUserDataOverride.test.ts b/apps/desktop/src/app/DesktopUserDataOverride.test.ts new file mode 100644 index 000000000..14d4bde3e --- /dev/null +++ b/apps/desktop/src/app/DesktopUserDataOverride.test.ts @@ -0,0 +1,95 @@ +// @effect-diagnostics nodeBuiltinImport:off - Bootstrap tests exercise POSIX and Windows path rules before an Effect runtime. +import * as NodePath from "node:path"; +import { assert, describe, it } from "vite-plus/test"; +import { + configureDesktopUserDataOverride, + resolveDesktopUserDataOverride, +} from "./DesktopUserDataOverride.ts"; + +describe("desktop client profile override", () => { + it("leaves default paths untouched when no override is selected", () => { + for (const directory of [undefined, "", " "]) { + configureDesktopUserDataOverride({ + directory, + path: NodePath.posix, + createDirectory: () => assert.fail("default profile must not be created here"), + setPath: () => assert.fail("default profile must not be rebound here"), + }); + } + }); + + it("creates and binds both storage paths synchronously in order", () => { + const operations: string[] = []; + configureDesktopUserDataOverride({ + directory: " /isolated/other/../profile ", + path: NodePath.posix, + createDirectory: (directory) => operations.push(`mkdir:${directory}`), + setPath: (name, directory) => operations.push(`${name}:${directory}`), + }); + assert.deepEqual(operations, [ + "mkdir:/isolated/profile", + "userData:/isolated/profile", + "sessionData:/isolated/profile", + ]); + }); + + it("rejects relative paths before any filesystem or Electron effect", () => { + assert.throws( + () => + configureDesktopUserDataOverride({ + directory: "relative/profile", + path: NodePath.posix, + createDirectory: () => assert.fail("invalid path must not touch disk"), + setPath: () => assert.fail("invalid path must not bind storage"), + }), + /must be an absolute path/, + ); + }); + + it("accepts absolute paths using the receiving platform's path rules", () => { + assert.equal( + resolveDesktopUserDataOverride("C:\\isolated\\profile", NodePath.win32), + "C:\\isolated\\profile", + ); + }); +}); + +describe("profile path validation and failure ordering", () => { + it.each([ + { path: NodePath.posix, directory: "relative/profile" }, + { path: NodePath.win32, directory: "C:relative-profile" }, + { path: NodePath.posix, directory: "/profile\0invalid" }, + { path: NodePath.win32, directory: "C:\\profile\0invalid" }, + ])("rejects invalid profile $directory before effects", ({ path, directory }) => { + const effects: string[] = []; + assert.throws(() => configureDesktopUserDataOverride({ + path, directory, + createDirectory: () => effects.push("create"), + setPath: () => effects.push("bind"), + })); + assert.deepEqual(effects, []); + }); + + it("preserves native path case and handles Windows UNC paths", () => { + assert.equal(resolveDesktopUserDataOverride("/Profiles/MixedCase", NodePath.posix), "/Profiles/MixedCase"); + assert.equal(resolveDesktopUserDataOverride("C:\\Profiles\\MixedCase", NodePath.win32), "C:\\Profiles\\MixedCase"); + assert.equal(resolveDesktopUserDataOverride("\\\\Host\\Share\\Profile", NodePath.win32), "\\\\Host\\Share\\Profile"); + }); + + it("does not bind storage if directory creation fails", () => { + const failure = new Error("synthetic mkdir failure"); + const effects: string[] = []; + let caught: unknown; + try { + configureDesktopUserDataOverride({ + directory: "/isolated/profile", path: NodePath.posix, + createDirectory: () => { throw failure; }, + setPath: () => effects.push("bind"), + }); + } catch (error) { + caught = error; + } + assert.strictEqual(caught, failure); + assert.deepEqual(effects, []); + }); +}); diff --git a/apps/desktop/src/app/DesktopUserDataOverride.ts b/apps/desktop/src/app/DesktopUserDataOverride.ts new file mode 100644 index 000000000..9bc2e0621 --- /dev/null +++ b/apps/desktop/src/app/DesktopUserDataOverride.ts @@ -0,0 +1,32 @@ +interface ProfilePath { + readonly isAbsolute: (value: string) => boolean; + readonly normalize: (value: string) => string; +} + +export function resolveDesktopUserDataOverride( + value: string | undefined, + path: ProfilePath, +): string | null { + const directory = value?.trim(); + if (!directory) return null; + if (directory.includes("\0")) { + throw new Error("T3CODE_DESKTOP_USER_DATA_DIR must not contain a null byte."); + } + if (!path.isAbsolute(directory)) { + throw new Error("T3CODE_DESKTOP_USER_DATA_DIR must be an absolute path."); + } + return path.normalize(directory); +} + +export function configureDesktopUserDataOverride(input: { + readonly directory: string | undefined; + readonly path: ProfilePath; + readonly createDirectory: (directory: string) => void; + readonly setPath: (name: "userData" | "sessionData", directory: string) => void; +}): void { + const directory = resolveDesktopUserDataOverride(input.directory, input.path); + if (directory === null) return; + input.createDirectory(directory); + input.setPath("userData", directory); + input.setPath("sessionData", directory); +} diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index c9eb35b24..18860d303 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,3 +1,18 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged bootstrap must bind Electron storage before any asynchronous initialization. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { configureDesktopUserDataOverride } from "./app/DesktopUserDataOverride.ts"; + +configureDesktopUserDataOverride({ + directory: process.env.T3CODE_DESKTOP_USER_DATA_DIR, + path: NodePath, + createDirectory: (directory) => NodeFS.mkdirSync(directory, { recursive: true }), + setPath: (name, directory) => { + const electron = require("electron") as typeof import("electron"); + electron.app.setPath(name, directory); + }, +}); + // Packaged app entry. Enables the compile cache before the main bundle loads, // so the cache also covers main.cjs itself. require("./compileCache.cjs"); From b072ea55bc8e7b2c11a3a5724c98e7cec904166d Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 04:13:29 +0200 Subject: [PATCH 13/59] fix(worktrees): accept messages before the default base resolves --- .../SettingsScheduledTasksRouteScreen.tsx | 33 +- .../settings/scheduledTaskDraft.test.ts | 66 +++ .../features/settings/scheduledTaskDraft.ts | 36 +- .../features/threads/NewTaskDraftScreen.tsx | 29 +- .../projectThreadCreationValidation.test.ts | 32 +- .../projectThreadCreationValidation.ts | 23 +- .../threads/projectThreadStartLatch.test.ts | 47 ++ .../threads/projectThreadStartLatch.ts | 15 + .../src/lib/projectThreadStartTurn.test.ts | 74 +++ apps/mobile/src/lib/projectThreadStartTurn.ts | 10 +- apps/mobile/src/state/thread-outbox-model.ts | 10 +- apps/mobile/src/state/thread-outbox.test.ts | 16 +- .../src/state/use-thread-outbox-drain.test.ts | 176 +++++- .../src/state/use-thread-outbox-drain.ts | 69 ++- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../server/src/git/GitWorkflowService.test.ts | 45 +- apps/server/src/git/GitWorkflowService.ts | 7 + .../ThreadLaunchService.test.ts | 550 +++++++++++++++++- .../orchestration-v2/ThreadLaunchService.ts | 105 +++- apps/server/src/vcs/GitVcsDriver.ts | 9 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 76 +++ apps/server/src/vcs/GitVcsDriverCore.ts | 27 + .../components/BranchToolbar.logic.test.ts | 52 ++ .../web/src/components/BranchToolbar.logic.ts | 18 + .../BranchToolbarBranchSelector.tsx | 18 +- .../web/src/components/ChatView.logic.test.ts | 41 ++ apps/web/src/components/ChatView.logic.ts | 20 + apps/web/src/components/ChatView.tsx | 52 +- .../settings/ScheduledTasksSettings.tsx | 7 +- .../scheduledTasksSettings.logic.test.ts | 25 + .../settings/scheduledTasksSettings.logic.ts | 15 +- docs/user/composer.md | 3 + .../src/operations/commands.test.ts | 323 +++++++++- .../client-runtime/src/operations/commands.ts | 32 +- packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 1 + .../contracts/src/orchestrationV2.test.ts | 20 + packages/contracts/src/orchestrationV2.ts | 2 +- packages/shared/src/git.test.ts | 35 ++ packages/shared/src/git.ts | 11 + 41 files changed, 1961 insertions(+), 181 deletions(-) create mode 100644 apps/mobile/src/features/threads/projectThreadStartLatch.test.ts create mode 100644 apps/mobile/src/features/threads/projectThreadStartLatch.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index a32198882..9fd686e8e 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1,9 +1,4 @@ -import type { - EnvironmentId, - ProjectId, - ScheduledTask, - ScheduledTaskUpsertInput, -} from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ScheduledTask } from "@t3tools/contracts"; import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { DateTimePicker } from "@expo/ui/community/datetime-picker"; @@ -58,6 +53,7 @@ import { editDraft, scheduledTaskDefaultModel, scheduleFromDraft, + scheduledTaskUpsertInputFromDraft, type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; @@ -617,29 +613,8 @@ function TaskForm({ Alert.alert("Project unavailable", "Choose a project in this environment."); return; } - const input: ScheduledTaskUpsertInput = { - ...(draft.task ? { id: draft.task.id, requireExisting: true } : {}), - title: draft.title.trim(), - prompt: draft.prompt.trim(), - projectId: draft.projectId, - modelSelection: draft.modelSelection, - schedule, - enabled: draft.enabled, - threadId: draft.task?.threadId ?? null, - workspaceStrategy: - draft.workspace === "root" - ? { type: "root" } - : draft.workspace === "existing_worktree" - ? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() } - : { - type: "worktree", - baseRef: draft.baseRef.trim() || "main", - startFromOrigin: draft.startFromOrigin, - }, - runtimeMode: draft.runtimeMode, - interactionMode: draft.task?.interactionMode ?? "default", - creationSource: draft.task?.creationSource ?? "mobile", - }; + const input = scheduledTaskUpsertInputFromDraft(draft); + if (input === null) return; // Lock before React renders, and keep successful creates locked until the form closes. submissionPending.current = true; setSaving(true); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 01961597c..e2455bad8 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -1,3 +1,4 @@ +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_SERVER_SETTINGS, @@ -6,6 +7,7 @@ import { ProjectId, ScheduledTaskId, type ScheduledTask, + ScheduledTaskUpsertInput, } from "@t3tools/contracts"; import { scheduledTaskDefaultModel, @@ -15,6 +17,7 @@ import { hasScheduledTaskDraftChanges, scheduleDraftForTask, scheduleFromDraft, + scheduledTaskUpsertInputFromDraft, } from "./scheduledTaskDraft"; describe("scheduleDraftForTask", () => { @@ -204,6 +207,19 @@ const legacyTask: ScheduledTask = { }; describe("editing scheduled task branch settings", () => { + it("opens an automatic worktree base as an empty editable field", () => { + const task = { + ...legacyTask, + workspaceStrategy: { type: "worktree" as const, startFromOrigin: true }, + }; + const draft = editDraft(task); + + expect(draft.baseRef).toBe(""); + expect(draft.startFromOrigin).toBe(true); + expect(draft.task?.workspaceStrategy).not.toHaveProperty("baseRef"); + expect(hasScheduledTaskDraftChanges(draft, editDraft(task))).toBe(false); + }); + it("keeps an omitted origin flag on the local base branch", () => { const draft = editDraft(legacyTask); expect(draft.baseRef).toBe("release"); @@ -309,3 +325,53 @@ describe("scheduled task model defaults", () => { ).toBeNull(); }); }); + +describe("scheduled-task form save and reopen", () => { + it.each([true, false])("keeps automatic base omitted with origin %s", (startFromOrigin) => { + const task: ScheduledTask = { + ...legacyTask, + workspaceStrategy: { type: "worktree", startFromOrigin }, + }; + const draft = editDraft(task); + const saved = Schema.decodeUnknownSync(ScheduledTaskUpsertInput)( + scheduledTaskUpsertInputFromDraft(draft), + ); + expect(saved.workspaceStrategy).toEqual({ type: "worktree", startFromOrigin }); + expect(saved.workspaceStrategy).not.toHaveProperty("baseRef"); + expect(saved).toMatchObject({ + id: task.id, + requireExisting: true, + title: task.title, + prompt: task.prompt, + projectId: task.projectId, + modelSelection: task.modelSelection, + schedule: task.schedule, + enabled: task.enabled, + threadId: task.threadId, + runtimeMode: task.runtimeMode, + interactionMode: task.interactionMode, + creationSource: task.creationSource, + }); + const reopened = editDraft({ ...task, ...saved }); + expect(reopened.baseRef).toBe(""); + expect(reopened.startFromOrigin).toBe(startFromOrigin); + expect(hasScheduledTaskDraftChanges(draft, reopened)).toBe(false); + }); + + it.each([true, false])("trims an explicit base without changing origin %s", (startFromOrigin) => { + const draft = { ...editDraft(legacyTask), baseRef: " release/stable ", startFromOrigin }; + const saved = Schema.decodeUnknownSync(ScheduledTaskUpsertInput)( + scheduledTaskUpsertInputFromDraft(draft), + ); + expect(saved.workspaceStrategy).toEqual({ + type: "worktree", + baseRef: "release/stable", + startFromOrigin, + }); + const reopened = editDraft({ ...legacyTask, ...saved }); + expect(reopened.baseRef).toBe("release/stable"); + expect(reopened.startFromOrigin).toBe(startFromOrigin); + expect(reopened.prompt).toBe(legacyTask.prompt); + expect(reopened.modelSelection).toEqual(legacyTask.modelSelection); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index b4d0a0fd9..0f20d1dc3 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -5,6 +5,7 @@ import type { RuntimeMode, ScheduledTask, ScheduledTaskUpsertSchedule, + ScheduledTaskUpsertInput, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -166,7 +167,8 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { modelSelectionIsExplicit: true, schedule: scheduleDraftForTask(task), workspace: task.workspaceStrategy.type, - baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main", + baseRef: + task.workspaceStrategy.type === "worktree" ? (task.workspaceStrategy.baseRef ?? "") : "main", checkoutPath: task.workspaceStrategy.type === "existing_worktree" ? task.workspaceStrategy.worktreePath @@ -179,3 +181,35 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { runtimeMode: task.runtimeMode, }; } + +/** Shared by the scheduled-task form and its save/reopen contract tests. */ +export function scheduledTaskUpsertInputFromDraft( + draft: ScheduledTaskDraft, +): ScheduledTaskUpsertInput | null { + const schedule = scheduleFromDraft(draft.schedule); + if (!draft.projectId || !draft.modelSelection || !schedule) return null; + const baseRef = draft.baseRef.trim(); + return { + ...(draft.task ? { id: draft.task.id, requireExisting: true } : {}), + title: draft.title.trim(), + prompt: draft.prompt.trim(), + projectId: draft.projectId, + modelSelection: draft.modelSelection, + schedule, + enabled: draft.enabled, + threadId: draft.task?.threadId ?? null, + workspaceStrategy: + draft.workspace === "root" + ? { type: "root" } + : draft.workspace === "existing_worktree" + ? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() } + : { + type: "worktree", + ...(baseRef === "" ? {} : { baseRef }), + startFromOrigin: draft.startFromOrigin, + }, + runtimeMode: draft.runtimeMode, + interactionMode: draft.task?.interactionMode ?? "default", + creationSource: draft.task?.creationSource ?? "mobile", + }; +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index d0da6e2f0..7affc781f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -123,6 +123,7 @@ import { useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; +import { createProjectThreadStartLatch } from "./projectThreadStartLatch"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { resolveNewTaskBranchLabel, @@ -308,6 +309,7 @@ export function NewTaskDraftScreen(props: { const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); + const startLatchRef = useRef(createProjectThreadStartLatch()); const [isComposerFocused, setIsComposerFocused] = useState(false); const [previewVideo, setPreviewVideo] = useState(null); const [previewFile, setPreviewFile] = useState(null); @@ -1004,15 +1006,15 @@ export function NewTaskDraftScreen(props: { flow.environments.find( (environment) => environment.environmentId === flow.selectedEnvironmentId, )?.environmentLabel ?? "Environment"; - const availableCurrentBranchName = - flow.availableBranches.find((branch) => branch.current)?.name ?? + const availableDefaultBranchName = flow.availableBranches.find((branch) => branch.isDefault)?.name ?? + flow.availableBranches.find((branch) => branch.current && !branch.isRemote)?.name ?? null; const selectedBranchName = resolveProjectThreadCreationBranch({ workspaceMode: flow.workspaceMode, selectedBranch: flow.selectedBranchName ?? - (flow.workspaceMode === "worktree" ? availableCurrentBranchName : null), + (flow.workspaceMode === "worktree" ? availableDefaultBranchName : null), currentCheckoutBranch: flow.currentCheckoutBranchName, }); const selectedBranchLabel = resolveNewTaskBranchLabel({ @@ -1208,6 +1210,17 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + if (flow.submitting) return; + await startLatchRef.current.run(async () => { + try { + await submitTask(); + } finally { + flow.setSubmitting(false); + } + }); + } + + async function submitTask(): Promise { if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; @@ -1223,16 +1236,13 @@ export function NewTaskDraftScreen(props: { selectedEnvironmentServerConfig, draft.modelSelection ?? null, ) ?? flow.selectedModel; - const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; - const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const initialMessageText = draft.text.trim(); if ( attachmentBlockReason !== null || !modelSelection || initialMessageText.length === 0 || - flow.submitting || - (workspaceMode === "worktree" && !selectedBranchName) + flow.submitting ) { return; } @@ -1327,8 +1337,6 @@ export function NewTaskDraftScreen(props: { error instanceof Error ? error.message : "The task could not be saved to the outbox.", ); return; - } finally { - flow.setSubmitting(false); } const draftSnapshot = getComposerDraftSnapshot(draftKey); if (editingPendingTask) { @@ -1387,8 +1395,7 @@ export function NewTaskDraftScreen(props: { !isImportingShare && !flow.submitting && pendingPastedTextAttachmentCount === 0 && - !voiceInput.blocksSubmission && - !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); + !voiceInput.blocksSubmission; const openDraftDocument = (attachment: ComposerDocumentAttachment) => { // A draft attachment lives only in the draft. Without its key the screen would fall through // to a remote lookup for bytes the server has never seen. diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts index 5c4980e6e..e2d695433 100644 --- a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts @@ -1,6 +1,36 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; +import { + resolveProjectThreadCreationBranch, + validateProjectThreadCreation, +} from "./projectThreadCreationValidation"; + +describe("validateProjectThreadCreation", () => { + it("accepts a worktree task while its automatic base is still loading", () => { + expect( + validateProjectThreadCreation({ + environmentId: EnvironmentId.make("environment"), + projectId: ProjectId.make("project"), + environmentMode: "worktree", + branch: null, + initialMessageText: "Start the task", + }), + ).toBeNull(); + }); + + it("still rejects an empty worktree task", () => { + expect( + validateProjectThreadCreation({ + environmentId: EnvironmentId.make("environment"), + projectId: ProjectId.make("project"), + environmentMode: "worktree", + branch: null, + initialMessageText: " \n ", + }), + ).toMatchObject({ _tag: "ProjectThreadTaskRequiredError" }); + }); +}); describe("resolveProjectThreadCreationBranch", () => { it("uses the live checkout for an untouched local draft label and recorded branch", () => { diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts index 13b195990..1a2489a6f 100644 --- a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts @@ -14,22 +14,7 @@ export class ProjectThreadTaskRequiredError extends Schema.TaggedError()( - "ProjectThreadBaseBranchRequiredError", - { - environmentId: EnvironmentId, - projectId: ProjectId, - }, -) { - override get message(): string { - return "Select a base branch before creating a worktree."; - } -} - -export const ProjectThreadCreationValidationError = Schema.Union([ - ProjectThreadTaskRequiredError, - ProjectThreadBaseBranchRequiredError, -]); +export const ProjectThreadCreationValidationError = ProjectThreadTaskRequiredError; export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; /** @@ -66,11 +51,5 @@ export function validateProjectThreadCreation(input: { environmentMode: input.environmentMode, }); } - if (input.environmentMode === "worktree" && !input.branch) { - return new ProjectThreadBaseBranchRequiredError({ - environmentId: input.environmentId, - projectId: input.projectId, - }); - } return null; } diff --git a/apps/mobile/src/features/threads/projectThreadStartLatch.test.ts b/apps/mobile/src/features/threads/projectThreadStartLatch.test.ts new file mode 100644 index 000000000..75f24c816 --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadStartLatch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createProjectThreadStartLatch } from "./projectThreadStartLatch"; + +describe("project thread start latch", () => { + it("accepts exactly one same-tick submission before minting identifiers and releases after success", async () => { + const latch = createProjectThreadStartLatch(); + const delivery = Promise.withResolvers(); + let nextId = 0; + const deliveredIds: number[] = []; + const submit = vi.fn(async () => { + const id = ++nextId; + await delivery.promise; + deliveredIds.push(id); + }); + + const first = latch.run(submit); + const duplicate = latch.run(submit); + + expect(nextId).toBe(1); + expect(submit).toHaveBeenCalledTimes(1); + delivery.resolve(); + await Promise.all([first, duplicate]); + expect(deliveredIds).toEqual([1]); + + await latch.run(submit); + expect(deliveredIds).toEqual([1, 2]); + }); + + it("releases after a failed submission so the same draft can retry", async () => { + const latch = createProjectThreadStartLatch(); + const delivery = Promise.withResolvers(); + const submit = vi.fn(() => delivery.promise); + const first = latch.run(submit); + const duplicate = latch.run(submit); + const failed = expect(first).rejects.toThrow("connection lost"); + + expect(submit).toHaveBeenCalledTimes(1); + delivery.reject(new Error("connection lost")); + await failed; + await duplicate; + + const retry = vi.fn(async () => undefined); + await latch.run(retry); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/features/threads/projectThreadStartLatch.ts b/apps/mobile/src/features/threads/projectThreadStartLatch.ts new file mode 100644 index 000000000..08ce8709a --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadStartLatch.ts @@ -0,0 +1,15 @@ +export function createProjectThreadStartLatch() { + let submitting = false; + + return { + async run(submit: () => Promise): Promise { + if (submitting) return; + submitting = true; + try { + await submit(); + } finally { + submitting = false; + } + }, + }; +} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index f67a4649f..aef60290a 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -11,8 +11,82 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProjectThreadStartTurnInput, deriveThreadTitleFromPrompt, + type ProjectThreadStartTurnSpec, } from "./projectThreadStartTurn"; +describe("project thread worktree bootstrap", () => { + const spec = { + projectId: ProjectId.make("project"), + projectCwd: "/workspace", + threadId: "new-thread", + commandId: "command", + messageId: "message", + createdAt: "2026-09-01T00:00:00Z", + text: "Start the task", + uploadedAttachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceMode: "worktree", + branch: null, + worktreePath: null, + startFromOrigin: false, + worktreeBranchName: "t3-task", + } satisfies ProjectThreadStartTurnSpec; + + it.each([true, false, undefined])( + "omits the automatic base and keeps the capability hint outside bootstrap (%s)", + (serverResolvesWorktreeBase) => { + const input = buildProjectThreadStartTurnInput({ ...spec, serverResolvesWorktreeBase }); + + expect(input.bootstrap.prepareWorktree).toEqual({ + projectCwd: spec.projectCwd, + branch: spec.worktreeBranchName, + }); + expect(input.bootstrap.createThread.branch).toBeNull(); + expect(input.bootstrap.runSetupScript).toBe(true); + expect(input.serverResolvesWorktreeBase).toBe(serverResolvesWorktreeBase); + expect(input.bootstrap).not.toHaveProperty("serverResolvesWorktreeBase"); + expect(input.commandId).toBe(spec.commandId); + expect(input.threadId).toBe(spec.threadId); + expect(input.message.messageId).toBe(spec.messageId); + expect(input.createdAt).toBe(spec.createdAt); + }, + ); + + it("preserves the chosen base and independent origin flag", () => { + const input = buildProjectThreadStartTurnInput({ + ...spec, + branch: "upstream/release", + startFromOrigin: true, + }); + + expect(input.bootstrap.prepareWorktree).toEqual({ + projectCwd: spec.projectCwd, + baseBranch: "upstream/release", + branch: spec.worktreeBranchName, + startFromOrigin: true, + }); + expect(input.bootstrap.createThread.branch).toBe("upstream/release"); + }); + + it("preserves the live local checkout without requesting worktree preparation", () => { + const input = buildProjectThreadStartTurnInput({ + ...spec, + workspaceMode: "local", + branch: "feature/current", + worktreePath: "/workspace/checkout", + }); + + expect(input.bootstrap.createThread).toMatchObject({ + branch: "feature/current", + worktreePath: "/workspace/checkout", + }); + expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); + expect(input.bootstrap).not.toHaveProperty("runSetupScript"); + }); +}); + describe("project thread title", () => { it("keeps ordinary titles and the empty-prompt fallback", () => { expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index fa1cc7042..7f74825fb 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -32,15 +32,12 @@ export interface ProjectThreadStartTurnSpec { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin: boolean; + readonly serverResolvesWorktreeBase?: boolean; /** Generated temp branch for worktree mode; unused for local mode. */ readonly worktreeBranchName: string; } -/** - * Single source of the `thread.turn.start` bootstrap payload used to create a - * thread from a project draft — shared by the immediate send path and the - * offline outbox drain so both deliver identical commands. - */ +/** Project-draft creation payload shared by connected submissions and offline outbox delivery. */ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) { const title = deriveThreadTitleSeed({ text: spec.text, attachments: spec.uploadedAttachments }); const isWorktree = spec.workspaceMode === "worktree"; @@ -59,6 +56,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe titleSeed: title, runtimeMode: spec.runtimeMode, interactionMode: spec.interactionMode, + serverResolvesWorktreeBase: spec.serverResolvesWorktreeBase, bootstrap: { createThread: { projectId: spec.projectId, @@ -74,7 +72,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe ? { prepareWorktree: { projectCwd: spec.projectCwd, - baseBranch: spec.branch!, + ...(spec.branch !== null ? { baseBranch: spec.branch } : {}), branch: spec.worktreeBranchName, ...(spec.startFromOrigin ? { startFromOrigin: true } : {}), }, diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 57a788cf5..8b089e795 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -240,8 +240,8 @@ export function resolveThreadOutboxDispatchStep(input: { } /** - * A queued creation can only be dispatched once its payload would pass server - * validation; incomplete payloads stay pending until the user edits them. + * A queued creation needs a task and model before dispatch; its base can stay + * automatic until delivery. Incomplete payloads stay pending until edited. */ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): boolean { if (!message.creation) { @@ -250,7 +250,11 @@ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): bo if (message.text.trim().length === 0 || message.modelSelection === undefined) { return false; } - return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); + return ( + message.creation.workspaceMode !== "worktree" || + message.creation.branch === null || + message.creation.branch.trim().length > 0 + ); } function errorMessage(error: unknown): string | null { diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 734caeab2..e9b06a413 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1390,7 +1390,7 @@ describe("thread outbox", () => { ).toBe("remove"); }); - it("round-trips queued creations and gates incomplete ones from sending", () => { + it("round-trips queued creations and accepts an automatic worktree base", () => { const base = queuedMessage({ messageId: "message-1", createdAt: "2026-06-08T10:00:01.000Z", @@ -1414,12 +1414,14 @@ describe("thread outbox", () => { creationMessage, ); expect(isQueuedThreadCreationSendable(creationMessage)).toBe(true); - expect( - isQueuedThreadCreationSendable({ - ...creationMessage, - creation: { ...creationMessage.creation, branch: null }, - }), - ).toBe(false); + const automaticCreation = { + ...creationMessage, + creation: { ...creationMessage.creation, branch: null }, + }; + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(automaticCreation))).toEqual( + automaticCreation, + ); + expect(isQueuedThreadCreationSendable(automaticCreation)).toBe(true); expect( isQueuedThreadCreationSendable({ ...creationMessage, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 890b29f9c..34247b9c7 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -8,6 +8,8 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; +import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; @@ -137,11 +139,16 @@ import { clearPendingThreadCreationOutcome, pendingThreadCreationOutcomesAtom, } from "./pending-thread-creation"; -import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + type QueuedThreadMessage, +} from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { + buildQueuedThreadCreationStartTurnInput, completeQueuedMessageDelivery, prepareQueuedMessageAttachments, recoverEditedCreationAfterDelivery, @@ -216,6 +223,173 @@ afterEach(() => { harness.setPendingConnectionError.mockClear(); }); +describe("queued worktree creation command", () => { + it.each([true, false])( + "preserves queued context and uploaded attachments at delivery (%s)", + (inlineMessageContext) => { + const terminal = { + version: 1 as const, + kind: "terminal" as const, + contextId: ComposerContextId.make("build-output"), + label: "Build output", + terminalId: "main", + terminalLabel: "Terminal", + lineStart: 4, + lineEnd: 5, + text: "build failed\nretry", + }; + const image = { + version: 1 as const, + kind: "image" as const, + contextId: ComposerContextId.make("screenshot"), + label: "Screenshot", + attachmentId: "draft-photo", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 123, + }; + const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + const uploadedAttachment = { + type: "image" as const, + id: "server-photo", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }; + const context = { version: 1 as const, records: [terminal, image] }; + const creation = { + projectId: ProjectId.make("project"), + workspaceMode: "worktree" as const, + branch: null, + worktreePath: null, + }; + const message = decodeQueuedThreadMessage( + encodeQueuedThreadMessage({ + ...queuedMessage({ + messageId: "context-worktree", + text: context.records.map(formatComposerContextReference).join(" "), + }), + modelSelection, + creation, + context, + attachments: [ + { + ...uploadedAttachment, + id: image.attachmentId, + fileUri: "file:///draft-photo.png", + previewUri: "file:///draft-photo.png", + }, + ], + }), + ); + const input = buildQueuedThreadCreationStartTurnInput({ + message, + creation, + projectCwd: "/current/workspace", + attachments: [uploadedAttachment], + settings: { modelSelection, runtimeMode: "full-access", interactionMode: "default" }, + serverResolvesWorktreeBase: true, + inlineMessageContext, + }); + + expect(input.message.attachments).toEqual([uploadedAttachment]); + if (inlineMessageContext) { + expect(input.message.text).toBe(message.text); + expect(input.message.context?.records).toEqual([ + terminal, + { ...image, attachmentId: uploadedAttachment.id }, + ]); + } else { + expect(input.message).not.toHaveProperty("context"); + expect(input.message.text).not.toContain("t3-context://"); + expect( + upgradeLegacyContextMessage(input.message.text).records.find( + (record) => record.kind === "terminal", + ), + ).toMatchObject({ + text: terminal.text, + lineStart: terminal.lineStart, + lineEnd: terminal.lineEnd, + }); + } + expect(message.context).toEqual(context); + }, + ); + + it.each([true, false])( + "delivers persisted automatic intent with current server support (%s)", + (serverResolvesWorktreeBase) => { + const message = decodeQueuedThreadMessage( + encodeQueuedThreadMessage({ + ...queuedMessage({ messageId: "automatic-worktree", text: " queued task " }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project"), + workspaceMode: "worktree", + branch: null, + worktreePath: null, + startFromOrigin: true, + }, + }), + ); + const input = buildQueuedThreadCreationStartTurnInput({ + message, + creation: message.creation!, + projectCwd: "/current/workspace", + attachments: [], + settings: { + modelSelection: message.modelSelection!, + runtimeMode: "full-access", + interactionMode: "default", + }, + serverResolvesWorktreeBase, + }); + + expect(input.bootstrap.prepareWorktree).toMatchObject({ + projectCwd: "/current/workspace", + startFromOrigin: true, + }); + expect(input.bootstrap.prepareWorktree).not.toHaveProperty("baseBranch"); + expect(input.bootstrap.createThread.branch).toBeNull(); + expect(input.serverResolvesWorktreeBase).toBe(serverResolvesWorktreeBase); + expect(input.commandId).toBe(message.commandId); + expect(input.threadId).toBe(message.threadId); + expect(input.message.messageId).toBe(message.messageId); + expect(input.createdAt).toBe(message.createdAt); + expect(input.message.text).toBe("queued task"); + }, + ); + + it("keeps a persisted explicit base and origin setting when delivered to an older server", () => { + const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + const message = decodeQueuedThreadMessage( + encodeQueuedThreadMessage({ + ...queuedMessage({ messageId: "explicit-worktree", text: "queued task" }), + modelSelection, + creation: { + projectId: ProjectId.make("project"), + workspaceMode: "worktree", + branch: "upstream/release", + worktreePath: null, + startFromOrigin: false, + }, + }), + ); + const input = buildQueuedThreadCreationStartTurnInput({ + message, + creation: message.creation!, + projectCwd: "/current/workspace", + attachments: [], + settings: { modelSelection, runtimeMode: "full-access", interactionMode: "default" }, + serverResolvesWorktreeBase: false, + }); + + expect(input.bootstrap.prepareWorktree).toMatchObject({ baseBranch: "upstream/release" }); + expect(input.bootstrap.prepareWorktree).not.toHaveProperty("startFromOrigin"); + expect(input.bootstrap.createThread.branch).toBe("upstream/release"); + }); +}); + describe("thread outbox attachment preparation", () => { it("abandons reused uploads when an editor saves changed text during verification", async () => { const message = withReusedFileUpload( diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 454980d40..50ef43d27 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -56,6 +56,7 @@ import { threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, + type ThreadSettingsSnapshot, type ThreadOutboxCommandStage, type ThreadOutboxFailureAction, } from "./thread-outbox-model"; @@ -205,6 +206,41 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma return CommandId.make(`${message.commandId}:${setting}`); } +export function buildQueuedThreadCreationStartTurnInput(input: { + readonly message: QueuedThreadMessage; + readonly creation: QueuedThreadCreation; + readonly projectCwd: string; + readonly attachments: PreparedTurnAttachments["attachments"]; + readonly settings: ThreadSettingsSnapshot; + readonly serverResolvesWorktreeBase: boolean; + readonly inlineMessageContext?: boolean; +}) { + const { message, creation, settings } = input; + return buildProjectThreadStartTurnInput({ + projectId: creation.projectId, + projectCwd: input.projectCwd, + threadId: message.threadId, + commandId: message.commandId, + messageId: message.messageId, + createdAt: message.createdAt, + ...serializeComposerMessageForServer( + message.text.trim(), + uploadedComposerContext(message.context, message.attachments, input.attachments), + input.inlineMessageContext === true, + ), + uploadedAttachments: input.attachments, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.interactionMode, + workspaceMode: creation.workspaceMode, + branch: creation.branch, + worktreePath: creation.worktreePath, + startFromOrigin: creation.startFromOrigin ?? false, + serverResolvesWorktreeBase: input.serverResolvesWorktreeBase, + worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + }); +} + /** * Uploads a queued message's attachments and persists the uploaded ids back * onto the queued message. The revision-checked update means an edit accepted @@ -1005,31 +1041,16 @@ export function useThreadOutboxDrain(): void { ); const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, - input: buildProjectThreadStartTurnInput({ - projectId: creation.projectId, + input: buildQueuedThreadCreationStartTurnInput({ + message: queuedMessage, + creation, projectCwd, - threadId: queuedMessage.threadId, - commandId: queuedMessage.commandId, - messageId: queuedMessage.messageId, - createdAt: queuedMessage.createdAt, - ...serializeComposerMessageForServer( - queuedMessage.text.trim(), - uploadedComposerContext( - queuedMessage.context, - queuedMessage.attachments, - prepared.attachments, - ), + attachments: prepared.attachments, + settings: sendSettings, + serverResolvesWorktreeBase: + currentConfig.environment.capabilities.worktreeDefaultBase === true, + inlineMessageContext: currentConfig.environment.capabilities.inlineMessageContext === true, - ), - uploadedAttachments: prepared.attachments, - modelSelection: sendSettings.modelSelection, - runtimeMode: sendSettings.runtimeMode, - interactionMode: sendSettings.interactionMode, - workspaceMode: creation.workspaceMode, - branch: creation.branch, - worktreePath: creation.worktreePath, - startFromOrigin: creation.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); const { reportFailure } = makeDeliveryHelpers(queuedMessage); @@ -1223,7 +1244,7 @@ export function useThreadOutboxDrain(): void { creation.projectCwd ?? null) : null; - // An incomplete pending task (e.g. worktree mode without a branch) stays + // An incomplete pending task (e.g. an empty prompt) stays // queued until the user finishes it in the editor. if (deliveryAction === "send" && creation !== undefined) { if (!isQueuedThreadCreationSendable(nextQueuedMessage)) { diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index f02e8e0ec..0313cd35b 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.worktreeDefaultBase).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 16a4a93b3..79492d9ba 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -216,6 +216,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + worktreeDefaultBase: true, attachmentUploads: true, questionAttachments: true, fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 5e7545eaf..37a8b4416 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { VcsRepositoryDetectionError } from "@t3tools/contracts"; +import { VcsRepositoryDetectionError, VcsUnsupportedOperationError } from "@t3tools/contracts"; import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; @@ -55,6 +55,49 @@ describe("GitWorkflowService", () => { ), ); + it.effect("keeps remote worktree lookup failures typed when repository resolution fails", () => { + const lookup = vi.fn(() => null); + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow + .resolveRemoteTrackingCommitIfExists({ + cwd: "/not-a-repo", + remoteName: "origin", + branchName: "develop", + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.resolveRemoteTrackingCommitIfExists", + cwd: "/not-a-repo", + }); + expect(lookup).not.toHaveBeenCalled(); + }).pipe( + Effect.provide( + GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => + Effect.fail( + new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.resolve", + kind: "unknown", + detail: "No Git repository is available.", + }), + ), + }), + ), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + resolveRemoteTrackingCommitIfExists: () => Effect.sync(lookup), + }), + ), + Layer.provide(Layer.mock(GitManager.GitManager)({})), + ), + ), + ); + }); + it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 912e0c1f4..16d76a321 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -94,6 +94,9 @@ export class GitWorkflowService extends Context.Service< { readonly commitSha: string; readonly remoteRefName: string }, GitCommandError >; + readonly resolveRemoteTrackingCommitIfExists: ( + input: GitVcsDriver.GitResolveRemoteTrackingCommitIfExistsInput, + ) => Effect.Effect; readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; @@ -369,6 +372,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( Effect.andThen(git.resolveRemoteTrackingCommit(input)), ), + resolveRemoteTrackingCommitIfExists: (input) => + ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommitIfExists", input.cwd).pipe( + Effect.andThen(git.resolveRemoteTrackingCommitIfExists(input)), + ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 5e5325091..435ff287a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -24,6 +24,7 @@ import { OrchestrationV2ThreadProjectionJson, ScheduledTaskId, type ServerProvider, + type VcsRef, ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -98,7 +99,10 @@ const adapter = { interface HarnessOptions { readonly managedFolders?: Layer.Layer; readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; + readonly remoteExists?: GitWorkflow.GitWorkflowService["Service"]["remoteExists"]; readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; + readonly listRefs?: GitWorkflow.GitWorkflowService["Service"]["listRefs"]; + readonly resolveRemoteTrackingCommitIfExists?: GitWorkflow.GitWorkflowService["Service"]["resolveRemoteTrackingCommitIfExists"]; readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"]; @@ -167,11 +171,24 @@ function makeHarness(options: HarnessOptions = {}) { createWorktree, renameBranch, fetchRemote: options.fetchRemote ?? (() => Effect.void), - remoteExists: () => Effect.succeed(true), - remoteBranchExists: () => Effect.succeed(true), + listRefs: + options.listRefs ?? + (() => + Effect.succeed({ + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + })), + resolveRemoteTrackingCommitIfExists: + options.resolveRemoteTrackingCommitIfExists ?? + (() => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" })), + remoteExists: options.remoteExists ?? (() => Effect.succeed(true)), + remoteBranchExists: () => Effect.die("The origin base must use a single commit lookup"), removeWorktree, resolveRemoteTrackingCommit: () => - Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), + Effect.die("The origin base must use a single commit lookup"), }), Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { runForThread: runSetup, @@ -1250,6 +1267,347 @@ it.effect("renames a temporary branch on an existing worktree to a generated nam }), ); +function worktreeBaseRef(name: string, overrides: Partial = {}): VcsRef { + return { + name, + isRemote: false, + current: false, + isDefault: false, + worktreePath: null, + ...overrides, + }; +} + +it.effect("accepts the first message while its automatic worktree base is still loading", () => + Effect.gen(function* () { + const refsEntered = yield* Deferred.make(); + const allowRefs = yield* Deferred.make(); + const harness = makeHarness({ + listRefs: () => + Deferred.succeed(refsEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowRefs)), + Effect.as({ + refs: [worktreeBaseRef("develop", { isDefault: true })], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "command:automatic-base-loading", + thread: "thread:automatic-base-loading", + message: "Start while branches load", + workspace: { type: "worktree", branch: "feature" }, + }), + ); + assert.equal(launched.projection.messages[0]?.text, "Start while branches load"); + assert.equal(launched.projection.runs[0]?.status, "preparing"); + yield* Deferred.await(refsEntered); + assert.equal(harness.createWorktree.mock.calls.length, 0); + assert.isNull((yield* tracker.get(launched.threadId))?.baseRef); + yield* Deferred.succeed(allowRefs, undefined); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting", + ), + Stream.runHead, + ); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, "develop"); + assert.equal((yield* tracker.get(launched.threadId))?.baseRef, "develop"); + assert.equal((yield* threads.getThreadProjection(launched.threadId)).messages.length, 1); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect.each([ + { + name: "default over checked-out", + refs: [ + worktreeBaseRef("feature", { current: true }), + worktreeBaseRef("develop", { isDefault: true }), + ], + expected: "develop", + }, + { + name: "local-only checked-out", + refs: [worktreeBaseRef("local", { current: true })], + expected: "local", + }, + { + name: "detached remote default", + refs: [ + worktreeBaseRef("origin/develop", { isDefault: true, isRemote: true, remoteName: "origin" }), + ], + expected: "origin/develop", + }, +])("resolves an omitted V2 base from $name", ({ refs, expected }) => { + const harness = makeHarness({ + listRefs: () => + Effect.succeed({ + refs, + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: refs.length, + }), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:automatic-base", + thread: "thread:automatic-base", + message: "Start", + workspace: { type: "worktree", branch: "feature" }, + }), + ); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting", + ), + Stream.runHead, + ); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, expected); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].baseRefName, expected); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect.each([ + { name: "non-repository", isRepo: false, refs: [], detail: "requires a Git repository" }, + { name: "empty repository", isRepo: true, refs: [], detail: "Select a base branch" }, + { + name: "detached local-only repository", + isRepo: true, + refs: [worktreeBaseRef("feature")], + detail: "Select a base branch", + }, +])( + "keeps the first message visible when automatic base resolution fails for $name", + ({ isRepo, refs, detail }) => { + const harness = makeHarness({ + listRefs: () => + Effect.succeed({ + refs, + isRepo, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: refs.length, + }), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:automatic-base-failed", + thread: "thread:automatic-base-failed", + message: "Keep this message", + workspace: { type: "worktree", branch: "feature" }, + }), + ); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "failed", + ), + Stream.runHead, + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.messages[0]?.text, "Keep this message"); + assert.isNull(projection.thread.worktreePath); + assert.include( + projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", + detail, + ); + assert.equal(harness.createWorktree.mock.calls.length, 0); + assert.equal(harness.runSetup.mock.calls.length, 0); + }).pipe(Effect.provide(harness.layer)); + }, +); + +it.effect("preserves an explicit V2 base without looking up the default", () => { + const harness = makeHarness({ + listRefs: () => Effect.die("Explicit base must not resolve the default"), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:explicit-base", + thread: "thread:explicit-base", + message: "Start", + workspace: { type: "worktree", baseRef: "release/stable", branch: "feature" }, + }), + ); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting", + ), + Stream.runHead, + ); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, "release/stable"); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect.each([ + { + name: "local base", + base: "main", + refs: [], + expectedBranch: "main", + expectedStart: "pinned-origin-sha", + fetchRef: "main", + }, + { + name: "origin base", + base: "origin/release", + refs: [worktreeBaseRef("origin/release", { isRemote: true, remoteName: "origin" })], + expectedBranch: "release", + expectedStart: "pinned-origin-sha", + fetchRef: "origin/release", + }, + { + name: "local origin-prefixed branch", + base: "origin/release", + refs: [worktreeBaseRef("origin/release")], + expectedBranch: "origin/release", + expectedStart: "pinned-origin-sha", + fetchRef: undefined, + }, + { + name: "other remote", + base: "upstream/release", + refs: [worktreeBaseRef("upstream/release", { isRemote: true, remoteName: "upstream" })], + expectedBranch: null, + expectedStart: "upstream/release", + fetchRef: undefined, + }, + { + name: "missing origin branch", + base: "local-only", + refs: [], + expectedBranch: "local-only", + expectedStart: "local-only", + fetchRef: "local-only", + }, +])( + "pins the fetched V2 worktree base with one lookup for $name", + ({ base, refs, expectedBranch, expectedStart, fetchRef }) => { + const operations: string[] = []; + const fetchRemote = vi.fn((_: Parameters>[0]) => + Effect.sync(() => { + operations.push("fetch"); + }), + ); + const resolveRemoteTrackingCommitIfExists = vi.fn( + (_: Parameters>[0]) => + Effect.sync(() => { + operations.push("resolve"); + return base === "local-only" + ? null + : { commitSha: "pinned-origin-sha", remoteRefName: `origin/${expectedBranch}` }; + }), + ); + const harness = makeHarness({ + listRefs: () => + Effect.succeed({ + refs, + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: refs.length, + }), + fetchRemote, + resolveRemoteTrackingCommitIfExists, + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:pinned-base", + thread: "thread:pinned-base", + message: "Start from origin", + workspace: { type: "worktree", baseRef: base, branch: "feature", startFromOrigin: true }, + }), + ); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting", + ), + Stream.runHead, + ); + assert.deepEqual(operations, expectedBranch === null ? ["fetch"] : ["fetch", "resolve"]); + assert.equal(fetchRemote.mock.calls.length, 1); + assert.equal(fetchRemote.mock.calls[0]?.[0].refName, fetchRef); + assert.equal( + resolveRemoteTrackingCommitIfExists.mock.calls.length, + expectedBranch === null ? 0 : 1, + ); + if (expectedBranch !== null) + assert.equal( + resolveRemoteTrackingCommitIfExists.mock.calls[0]?.[0].branchName, + expectedBranch, + ); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, expectedStart); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].baseRefName, base); + }).pipe(Effect.provide(harness.layer)); + }, +); + +it.effect("keeps lookup failures visible without creating a worktree or starting its run", () => { + const harness = makeHarness({ + resolveRemoteTrackingCommitIfExists: () => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.resolveRemoteTrackingCommitIfExists", + cwd: "/repo", + command: "git rev-parse", + detail: "Remote ref lookup failed", + }), + ), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:lookup-failed", + thread: "thread:lookup-failed", + message: "Keep this message", + workspace: { type: "worktree", baseRef: "main", branch: "feature", startFromOrigin: true }, + }), + ); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => stored.event.type === "run.updated" && stored.event.payload.status === "failed", + ), + Stream.runHead, + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.messages[0]?.text, "Keep this message"); + assert.include( + projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", + "Remote ref lookup failed", + ); + assert.equal(harness.createWorktree.mock.calls.length, 0); + assert.equal(harness.runSetup.mock.calls.length, 0); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("shows the fetch diagnosis when preparing a worktree from origin fails", () => { const detail = "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; @@ -2216,3 +2574,189 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit }).pipe(Effect.provide(harness.layer)); }), ); + +it.effect( + "an automatic-base retry reuses the recorded worktree without selecting another base", + () => { + let setupFailures = 1; + let lookups = 0; + const harness = makeHarness({ + listRefs: () => + Effect.sync(() => { + lookups += 1; + return { + refs: [worktreeBaseRef("main", { isDefault: true })], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 1, + }; + }), + runSetup: () => + setupFailures-- > 0 + ? Effect.fail(new Error("setup failed") as never) + : Effect.succeed({ status: "no-script" as const }), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:reuse", + thread: "thread:launch:reuse", + message: "Reuse the worktree", + workspace: { type: "worktree" }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe( + Effect.map( + (projection) => + projection.runs[0]?.status === "failed" && + projection.thread.branch === "generated-branch", + ), + ), + ); + const failed = yield* threads.getThreadProjection(launched.threadId); + assert.equal(failed.thread.worktreePath, "/repo-worktrees/feature"); + + yield* launches.retryPreparation({ + commandId: CommandId.make("command:launch:reuse:retry"), + threadId: launched.threadId, + runId: failed.runs[0]!.id, + }); + yield* waitUntil(() => + outbox + .listByCommandId(CommandId.make("command:launch:reuse:retry:release")) + .pipe(Effect.map((effects) => effects.length === 1)), + ); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs[0]?.status, "starting"); + // The retry neither checks out again nor puts back the temporary branch. + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.renameBranch.mock.calls.length, 1); + assert.equal(retried.thread.branch, "generated-branch"); + assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); + // Clients see the retry's setup, not the failed one it replaced. + const snapshot = yield* tracker.get(launched.threadId); + assert.equal(snapshot?.phase, "done"); + assert.isNull(snapshot?.baseRef); + assert.equal(lookups, 1); + assert.deepEqual( + snapshot?.stages.map((stage) => stage.id), + ["setup-script", "agent"], + ); + }).pipe(Effect.provide(harness.layer)); + }, +); + +it.effect.each([ + { caseName: "the origin remote is missing", hasOrigin: false }, + { caseName: "the base branch exists only locally", hasOrigin: true }, +])("uses the local V2 worktree base when $caseName", ({ hasOrigin }) => { + const fetchRemote = vi.fn(() => Effect.void); + const remoteLookup = vi.fn(() => Effect.succeed(null)); + const harness = makeHarness({ + remoteExists: () => Effect.succeed(hasOrigin), + fetchRemote, + resolveRemoteTrackingCommitIfExists: remoteLookup, + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:local-base", + thread: "thread:local-base", + message: "Start locally", + workspace: { + type: "worktree", + baseRef: "main", + branch: "feature/router", + startFromOrigin: true, + }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "starting")), + ); + assert.equal(fetchRemote.mock.calls.length, hasOrigin ? 1 : 0); + assert.equal(remoteLookup.mock.calls.length, hasOrigin ? 1 : 0); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, "main"); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].baseRefName, "main"); + assert.equal( + (yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath, + "/repo-worktrees/feature", + ); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect( + "prefers an exact local branch found on a later ref page over an ambiguous origin ref", + () => { + const listRefs = vi.fn((input: Parameters>[0]) => + Effect.succeed({ + refs: + input.cursor === undefined + ? [worktreeBaseRef("origin/release", { isRemote: true, remoteName: "origin" })] + : [worktreeBaseRef("origin/release")], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: input.cursor === undefined ? 1 : null, + totalCount: 2, + }), + ); + const remoteLookup = vi.fn( + (input: Parameters>[0]) => + Effect.succeed({ + commitSha: "local-origin-prefixed-sha", + remoteRefName: `origin/${input.branchName}`, + }), + ); + const fetchRemote = vi.fn( + (_: Parameters>[0]) => Effect.void, + ); + const harness = makeHarness({ + listRefs, + resolveRemoteTrackingCommitIfExists: remoteLookup, + fetchRemote, + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:paged-local", + thread: "thread:paged-local", + message: "Use the local spelling", + workspace: { + type: "worktree", + baseRef: "origin/release", + branch: "feature", + startFromOrigin: true, + }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "starting")), + ); + assert.deepEqual( + listRefs.mock.calls.map(([input]) => input.cursor), + [undefined, 1], + ); + assert.equal(remoteLookup.mock.calls.length, 1); + assert.equal(remoteLookup.mock.calls[0]?.[0].branchName, "origin/release"); + assert.isUndefined(fetchRemote.mock.calls[0]?.[0].refName); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, "local-origin-prefixed-sha"); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].baseRefName, "origin/release"); + }).pipe(Effect.provide(harness.layer)); + }, +); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 555d3266e..df7012ad1 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -16,6 +16,7 @@ import { ProjectId, type RunId, type RuntimeMode, + type VcsRef, type ScheduledTaskId, ThreadId, } from "@t3tools/contracts"; @@ -28,7 +29,11 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import { + buildTemporaryWorktreeBranchName, + isTemporaryWorktreeBranch, + resolveDefaultWorktreeBaseBranch, +} from "@t3tools/shared/git"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -53,7 +58,7 @@ export type ThreadLaunchWorkspaceStrategy = } | { readonly type: "worktree"; - readonly baseRef: string; + readonly baseRef?: string | undefined; readonly branch?: string | undefined; readonly startFromOrigin?: boolean | undefined; }; @@ -101,7 +106,7 @@ type PreparationInput = Pick< * recorded. Its setup is tracked like a new one, but the thread already * records the workspace, and a branch rename may still be running. */ - readonly reusedWorktree?: { readonly baseRef: string }; + readonly reusedWorktree?: { readonly baseRef: string | undefined }; }; export interface ThreadLaunchRetryInput { @@ -249,7 +254,7 @@ const make = Effect.gen(function* () { yield* setupTracker.begin({ threadId, branch: input.workspaceStrategy.branch ?? null, - baseRef: input.workspaceStrategy.baseRef, + baseRef: input.workspaceStrategy.baseRef ?? null, stages: ["fetch", "checkout", "setup-script", "agent"], fiber: yield* Effect.fiber, }); @@ -257,7 +262,7 @@ const make = Effect.gen(function* () { yield* setupTracker.begin({ threadId, branch: input.workspaceStrategy.branch ?? null, - baseRef: reused.baseRef, + baseRef: reused.baseRef ?? null, stages: ["setup-script", "agent"], fiber: yield* Effect.fiber, }); @@ -325,7 +330,32 @@ const make = Effect.gen(function* () { }) .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); } - let startRef = input.workspaceStrategy.baseRef; + let baseRef = input.workspaceStrategy.baseRef; + let selectedBase: VcsRef | undefined; + if (baseRef === undefined) { + const refs = yield* git + .listRefs({ cwd: project.workspaceRoot, limit: 100 }) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); + const automaticBase = resolveDefaultWorktreeBaseBranch(refs.refs); + if (!refs.isRepo || automaticBase === null) { + return yield* mapError( + input, + "provision-worktree", + threadId, + )( + refs.isRepo + ? "No default or checked-out branch is available. Select a base branch before retrying." + : "New worktree mode requires a Git repository.", + ); + } + baseRef = automaticBase; + selectedBase = refs.refs.find((ref) => ref.name === baseRef); + yield* setupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + baseRef: automaticBase, + })); + } + let startRef = baseRef; // "Start from origin" is a stored default; repos without the requested // remote branch fall back to the local base branch. const startFromOrigin = @@ -335,32 +365,53 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)))); yield* setupTracker.stageStatus(threadId, "fetch", startFromOrigin ? "running" : "skipped"); if (startFromOrigin) { + if (selectedBase === undefined && baseRef.includes("/")) { + let cursor: number | undefined; + do { + const refs = yield* git + .listRefs({ + cwd: project.workspaceRoot, + query: baseRef, + limit: 100, + includeMatchingRemoteRefs: true, + ...(cursor === undefined ? {} : { cursor }), + }) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); + const localBase = refs.refs.find((ref) => ref.name === baseRef && !ref.isRemote); + selectedBase = + localBase ?? selectedBase ?? refs.refs.find((ref) => ref.name === baseRef); + if (localBase || refs.nextCursor === null) break; + cursor = refs.nextCursor; + } while (true); + } + const selectedRemote = selectedBase?.isRemote ? selectedBase.remoteName : undefined; + const branchName = + selectedRemote === "origin" ? baseRef.slice("origin/".length) : baseRef; + // Scoped fetch treats origin/ as a remote prefix, so fetch all refs + // when that spelling belongs to a local branch or another remote. + const ambiguousOriginPrefix = + selectedRemote === undefined && baseRef.startsWith("origin/"); yield* git .fetchRemote({ cwd: project.workspaceRoot, remoteName: "origin", - refName: input.workspaceStrategy.baseRef, + ...(!ambiguousOriginPrefix && + (selectedRemote === undefined || selectedRemote === "origin") + ? { refName: baseRef } + : {}), }) .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); - const remoteBaseExists = yield* git - .remoteBranchExists({ - cwd: project.workspaceRoot, - refName: input.workspaceStrategy.baseRef, - remoteName: "origin", - }) - .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); - if (remoteBaseExists) { - startRef = yield* git - .resolveRemoteTrackingCommit({ - cwd: project.workspaceRoot, - refName: input.workspaceStrategy.baseRef, - fallbackRemoteName: "origin", - }) - .pipe( - Effect.map((resolved) => resolved.commitSha), - Effect.mapError(mapError(input, "provision-worktree", threadId)), - ); - } + const resolvedRemoteBase = + selectedRemote === undefined || selectedRemote === "origin" + ? yield* git + .resolveRemoteTrackingCommitIfExists({ + cwd: project.workspaceRoot, + remoteName: "origin", + branchName, + }) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))) + : null; + if (resolvedRemoteBase !== null) startRef = resolvedRemoteBase.commitSha; } if (startFromOrigin) yield* setupTracker.stageStatus(threadId, "fetch", "done"); yield* setupTracker.stageStatus(threadId, "checkout", "running"); @@ -370,7 +421,7 @@ const make = Effect.gen(function* () { cwd: project.workspaceRoot, refName: startRef, newRefName: branch!, - baseRefName: input.workspaceStrategy.baseRef, + baseRefName: baseRef, path: null, }, { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 8dd8b7664..e01e363ad 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -289,6 +289,12 @@ export interface GitResolveRemoteTrackingCommitResult { remoteRefName: string; } +export interface GitResolveRemoteTrackingCommitIfExistsInput { + readonly cwd: string; + readonly remoteName: string; + readonly branchName: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -379,6 +385,9 @@ export class GitVcsDriver extends Context.Service< readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; + readonly resolveRemoteTrackingCommitIfExists: ( + input: GitResolveRemoteTrackingCommitIfExistsInput, + ) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fd61a360e..6792d76a2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -86,6 +86,66 @@ const makeTmpDir = ( return yield* fileSystem.makeTempDirectoryScoped({ prefix }); }); +describe("resolveRemoteTrackingCommitIfExists", () => { + it.effect.each([ + { name: "present", exitCode: 0, stderr: "", expected: "commit" }, + { name: "absent", exitCode: 1, stderr: "", expected: "missing" }, + { + name: "repository failure", + exitCode: 128, + stderr: "fatal: invalid repository", + expected: "error", + }, + { name: "invalid object", exitCode: 1, stderr: "error: invalid object", expected: "error" }, + ])("resolves an exact remote ref once: $name", ({ exitCode, stderr, expected }) => + Effect.scoped( + Effect.gen(function* () { + const commands: Array> = []; + const commitSha = "0123456789abcdef0123456789abcdef01234567"; + const spawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) { + return Effect.die("expected a standard Git command"); + } + commands.push(command.args); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(exitCode === 0 ? `${commitSha}\n` : ""), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + stderr: Stream.encodeText(Stream.make(stderr)), + }), + ); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const lookup = driver.resolveRemoteTrackingCommitIfExists({ + cwd: "/repo", + remoteName: "origin", + branchName: "release/stable", + }); + if (expected === "error") { + const error = yield* lookup.pipe(Effect.flip); + assert.instanceOf(error, GitCommandError); + assert.equal(error.detail, stderr); + } else { + assert.deepEqual( + yield* lookup, + expected === "missing" + ? null + : { + commitSha, + remoteRefName: "origin/release/stable", + }, + ); + } + assert.deepEqual(commands, [ + ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/release/stable^{commit}"], + ]); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), + ); +}); + const writeTextFile = ( cwd: string, relativePath: string, @@ -3225,6 +3285,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { remoteRefName: `origin/${initialBranch}`, }); assert.deepEqual(explicitlyResolvedBase, resolvedBase); + assert.deepEqual( + yield* driver.resolveRemoteTrackingCommitIfExists({ + cwd, + remoteName: "origin", + branchName: initialBranch, + }), + resolvedBase, + ); + assert.equal( + yield* driver.resolveRemoteTrackingCommitIfExists({ + cwd, + remoteName: "origin", + branchName: "missing/local-only", + }), + null, + ); assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch); const pathService = yield* Path.Path; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 971d0e7a9..3770c6ad1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3518,6 +3518,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha, remoteRefName }; }); + const resolveRemoteTrackingCommitIfExists: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommitIfExists"] = + Effect.fn("resolveRemoteTrackingCommitIfExists")(function* (input) { + const remoteRefName = `${input.remoteName}/${input.branchName}`; + const args = ["rev-parse", "--verify", "--quiet", `refs/remotes/${remoteRefName}^{commit}`]; + const result = yield* executeGit( + "GitVcsDriver.resolveRemoteTrackingCommitIfExists", + input.cwd, + args, + { + allowNonZeroExit: true, + }, + ); + if (result.exitCode === 1 && result.stderr.trim().length === 0) { + return null; + } + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.resolveRemoteTrackingCommitIfExists", + command: `git ${args.join(" ")}`, + cwd: input.cwd, + detail: result.stderr.trim() || "Could not resolve the remote worktree base.", + }); + } + return { commitSha: result.stdout.trim(), remoteRefName }; + }); + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { @@ -3836,6 +3862,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* remoteExists, remoteBranchExists, resolveRemoteTrackingCommit, + resolveRemoteTrackingCommitIfExists, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 4de529a3d..79e635aff 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,6 +8,7 @@ import { resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveAutomaticWorktreeBaseBranch, resolveEnvModeLabel, resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, @@ -896,3 +897,54 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); }); }); + +describe("resolveAutomaticWorktreeBaseBranch", () => { + const pendingSelection = { + effectiveEnvMode: "worktree" as const, + envLocked: false, + activeWorktreePath: null, + activeThreadBranch: null, + worktreeBaseBranchCandidate: null, + }; + + it("suppresses a late default during submission and after bootstrap writes the worktree", () => { + const metadataWrites: Array<{ branch: string; worktreePath: null }> = []; + const applyAutomaticSelection = ( + input: Parameters[0], + ) => { + const branch = resolveAutomaticWorktreeBaseBranch(input); + if (branch !== null) metadataWrites.push({ branch, worktreePath: null }); + }; + applyAutomaticSelection(pendingSelection); + applyAutomaticSelection({ + ...pendingSelection, + envLocked: true, + worktreeBaseBranchCandidate: "develop", + }); + applyAutomaticSelection({ + ...pendingSelection, + activeWorktreePath: "/repo/worktree", + worktreeBaseBranchCandidate: "develop", + }); + expect(metadataWrites).toEqual([]); + applyAutomaticSelection({ ...pendingSelection, worktreeBaseBranchCandidate: "develop" }); + expect(metadataWrites).toEqual([{ branch: "develop", worktreePath: null }]); + }); + + it("keeps an explicit choice and never auto-selects in the local checkout", () => { + expect( + resolveAutomaticWorktreeBaseBranch({ + ...pendingSelection, + activeThreadBranch: "chosen/base", + worktreeBaseBranchCandidate: "develop", + }), + ).toBeNull(); + expect( + resolveAutomaticWorktreeBaseBranch({ + ...pendingSelection, + effectiveEnvMode: "local", + worktreeBaseBranchCandidate: "develop", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d018802a5..59840f6c3 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -334,3 +334,21 @@ export function shouldIncludeBranchPickerItem(input: { lowerItemValue.includes(sanitizedQuery) ); } + +export function resolveAutomaticWorktreeBaseBranch(input: { + effectiveEnvMode: EnvMode; + envLocked: boolean; + activeWorktreePath: string | null; + activeThreadBranch: string | null; + worktreeBaseBranchCandidate: string | null; +}): string | null { + if ( + input.envLocked || + input.effectiveEnvMode !== "worktree" || + input.activeWorktreePath !== null || + input.activeThreadBranch !== null + ) { + return null; + } + return input.worktreeBaseBranchCandidate; +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 129528295..9bb62a908 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -50,6 +50,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveAutomaticWorktreeBaseBranch, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -507,19 +508,22 @@ export function BranchToolbarBranchSelector({ : (defaultBranchName ?? currentGitBranch); useEffect(() => { - if ( - effectiveEnvMode !== "worktree" || - activeWorktreePath || - activeThreadBranch || - !worktreeBaseBranchCandidate - ) { + const branch = resolveAutomaticWorktreeBaseBranch({ + effectiveEnvMode, + envLocked, + activeWorktreePath, + activeThreadBranch, + worktreeBaseBranchCandidate, + }); + if (branch === null) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null, true); + setThreadBranch(branch, null, true); }, [ activeThreadBranch, activeWorktreePath, effectiveEnvMode, + envLocked, setThreadBranch, worktreeBaseBranchCandidate, ]); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9e6b96b21..3aff6ec49 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -79,6 +79,7 @@ import { resolveEffectiveInteractionMode, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveFirstSendWorktreePreparation, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, @@ -2160,3 +2161,43 @@ describe("waitForRevertedMessage", () => { vi.useRealTimers(); }); }); + +describe("resolveFirstSendWorktreePreparation", () => { + const automaticInput = { + isFirstMessage: true, + sendEnvMode: "worktree" as const, + worktreePath: null, + projectCwd: "/repo", + baseBranch: null, + startFromOrigin: true, + }; + + it("prepares the first worktree send while base selection is still loading", () => { + expect(resolveFirstSendWorktreePreparation(automaticInput)).toEqual({ + projectCwd: "/repo", + startFromOrigin: true, + }); + }); + + it("preserves the captured explicit base and independent origin preference", () => { + expect( + resolveFirstSendWorktreePreparation({ + ...automaticInput, + baseBranch: "upstream/release", + startFromOrigin: false, + }), + ).toEqual({ projectCwd: "/repo", baseBranch: "upstream/release" }); + }); + + it("does not prepare local, subsequent, or existing-worktree sends", () => { + expect( + resolveFirstSendWorktreePreparation({ ...automaticInput, sendEnvMode: "local" }), + ).toBeUndefined(); + expect( + resolveFirstSendWorktreePreparation({ ...automaticInput, isFirstMessage: false }), + ).toBeUndefined(); + expect( + resolveFirstSendWorktreePreparation({ ...automaticInput, worktreePath: "/repo/worktree" }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3323a8f3e..2a2b66fce 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,5 @@ import * as Option from "effect/Option"; +import type { StartThreadTurnInput } from "@t3tools/client-runtime/operations"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, @@ -827,6 +828,25 @@ export function readFileAsDataUrl(file: File): Promise { }); } +// Preserve New worktree intent while automatic base selection is still loading. +export function resolveFirstSendWorktreePreparation(input: { + isFirstMessage: boolean; + sendEnvMode: DraftThreadEnvMode; + worktreePath: string | null; + projectCwd: string; + baseBranch: string | null; + startFromOrigin: boolean; +}): NonNullable["prepareWorktree"] { + if (!input.isFirstMessage || input.sendEnvMode !== "worktree" || input.worktreePath !== null) { + return undefined; + } + return { + projectCwd: input.projectCwd, + ...(input.baseBranch !== null ? { baseBranch: input.baseBranch } : {}), + ...(input.startFromOrigin ? { startFromOrigin: true } : {}), + }; +} + export function resolveSendEnvMode(input: { requestedEnvMode: DraftThreadEnvMode; isGitRepo: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e001dad66..786df4c20 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -527,6 +527,7 @@ import { resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveFirstSendWorktreePreparation, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, startNewThreadForProject, @@ -8416,15 +8417,12 @@ export default function ChatView(props: ChatViewProps) { } if ( multipleModelSelections !== null && - (!isLocalDraftThread || - !isGitRepo || - !activeThreadBranch || - multipleModelSelections.length === 0) + (!isLocalDraftThread || !isGitRepo || multipleModelSelections.length === 0) ) { toastManager.add( stackedThreadToast({ type: "warning", - title: "Choose models and a base branch", + title: "Choose models in a Git project", description: "Multiple models need a new thread in a Git project. Each gets its own worktree.", }), @@ -8800,19 +8798,15 @@ export default function ChatView(props: ChatViewProps) { } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeMessageCount === 0; - const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch - : null; - - // In worktree mode, require an explicit base branch so we don't silently - // fall back to local execution when branch selection is missing. - const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { - setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); - return; - } + const worktreePreparation = resolveFirstSendWorktreePreparation({ + isFirstMessage, + sendEnvMode, + worktreePath: activeThread.worktreePath, + projectCwd: activeProject.workspaceRoot, + baseBranch: activeThreadBranch, + startFromOrigin, + }); + const shouldCreateWorktree = worktreePreparation !== undefined; const composerImagesSnapshot = [...composerImages]; const composerFilesSnapshot = [...composerFiles]; @@ -8993,13 +8987,13 @@ export default function ChatView(props: ChatViewProps) { await dockStarted; } beginLocalDispatch({ - preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), + preparingWorktree: multipleModelSelections !== null || shouldCreateWorktree, // Only a draft has a background submission to hide behind its hero. submissionIntent: submissionIntent === "background" && !isLocalDraftThread ? "foreground" : submissionIntent, }); setWorktreeSetupRef( - multipleModelSelections === null && baseBranchForWorktree + multipleModelSelections === null && shouldCreateWorktree ? { environmentId: activeThread.environmentId, threadId: threadIdForSend, @@ -9092,6 +9086,9 @@ export default function ChatView(props: ChatViewProps) { environmentId, input: { threadId: targetThreadId, + serverResolvesWorktreeBase: + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) + ?.environment.capabilities.worktreeDefaultBase === true, message: { messageId: newMessageId(), role: "user", @@ -9122,7 +9119,7 @@ export default function ChatView(props: ChatViewProps) { }, prepareWorktree: { projectCwd: activeProject.workspaceRoot, - baseBranch: activeThreadBranch!, + ...(activeThreadBranch === null ? {} : { baseBranch: activeThreadBranch }), requireWorktree: true, ...(startFromOrigin ? { startFromOrigin: true } : {}), }, @@ -9446,7 +9443,7 @@ export default function ChatView(props: ChatViewProps) { let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = - isLocalDraftThread || baseBranchForWorktree + isLocalDraftThread || shouldCreateWorktree ? { ...(isLocalDraftThread ? { @@ -9462,12 +9459,10 @@ export default function ChatView(props: ChatViewProps) { }, } : {}), - ...(baseBranchForWorktree + ...(worktreePreparation ? { prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: baseBranchForWorktree, - ...(startFromOrigin ? { startFromOrigin: true } : {}), + ...worktreePreparation, }, runSetupScript: true, } @@ -9483,6 +9478,9 @@ export default function ChatView(props: ChatViewProps) { environmentId, input: { threadId: threadIdForSend, + serverResolvesWorktreeBase: + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities.worktreeDefaultBase === true, message: { messageId: messageIdForSend, role: "user", @@ -11349,7 +11347,7 @@ export default function ChatView(props: ChatViewProps) { setPendingServerThreadBranch, } : {})} - envLocked={envLocked} + envLocked={envLocked || isSendBusy} onComposerFocusRequest={scheduleComposerFocus} {...(canCheckoutPullRequestIntoThread ? { onCheckoutPullRequestRequest: openPullRequestDialog } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 15380c186..2323f784f 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -49,6 +49,7 @@ import { matchesScheduledTaskScope, scheduledTaskDefaultModel, taskToDraft, + worktreeStrategyFromDraft, type DraftState, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; @@ -590,11 +591,7 @@ function ScheduledTaskEditorDialog({ ? { type: "root" } : draft.workspaceMode === "existing_worktree" ? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() } - : { - type: "worktree", - baseRef: draft.baseRef.trim() || "main", - startFromOrigin: draft.startFromOrigin, - }; + : worktreeStrategyFromDraft(draft); const input: ScheduledTaskUpsertInput = { ...(draft.editingId ? { id: draft.editingId as ScheduledTaskId, requireExisting: true } : {}), title: draft.title.trim(), diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 51b5576de..a0d67b9e9 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -20,6 +20,7 @@ import { scheduledTaskDefaultModel, matchesScheduledTaskScope, taskToDraft, + worktreeStrategyFromDraft, } from "./scheduledTasksSettings.logic"; const laptopId = EnvironmentId.make("laptop"); @@ -153,6 +154,30 @@ const legacyTask: ScheduledTask = { }; describe("editing scheduled task branch settings", () => { + it.each([true, false])( + "preserves an automatic base with origin preference %s when saving", + (startFromOrigin) => { + const draft = taskToDraft({ + ...legacyTask, + workspaceStrategy: { type: "worktree", startFromOrigin }, + }); + expect(draft.baseRef).toBe(""); + expect(worktreeStrategyFromDraft(draft)).toEqual({ type: "worktree", startFromOrigin }); + }, + ); + + it("preserves and trims an explicitly selected base when saving", () => { + const draft = taskToDraft({ + ...legacyTask, + workspaceStrategy: { type: "worktree", baseRef: "upstream/release", startFromOrigin: true }, + }); + expect(worktreeStrategyFromDraft({ ...draft, baseRef: ` ${draft.baseRef} ` })).toEqual({ + type: "worktree", + baseRef: "upstream/release", + startFromOrigin: true, + }); + }); + it("keeps an omitted origin flag on the local base branch", () => { const draft = taskToDraft(legacyTask); expect(draft.baseRef).toBe("release"); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index fcdb186ed..c6303f2c2 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -3,6 +3,7 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type OrchestrationV2ThreadLaunchWorkspaceStrategy, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -92,7 +93,8 @@ export function taskToDraft(task: ScheduledTask): DraftState { projectId: task.projectId, threadId: task.threadId ?? "", workspaceMode: task.workspaceStrategy.type, - baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main", + baseRef: + task.workspaceStrategy.type === "worktree" ? (task.workspaceStrategy.baseRef ?? "") : "main", startFromOrigin: task.workspaceStrategy.type === "worktree" ? (task.workspaceStrategy.startFromOrigin ?? false) @@ -108,6 +110,17 @@ export function taskToDraft(task: ScheduledTask): DraftState { }; } +export function worktreeStrategyFromDraft( + draft: Pick, +): Extract { + const baseRef = draft.baseRef.trim(); + return { + type: "worktree", + ...(baseRef ? { baseRef } : {}), + startFromOrigin: draft.startFromOrigin, + }; +} + /** Use configured defaults before the catalog's advertised default model. */ export function scheduledTaskDefaultModel( settings: ServerSettings, diff --git a/docs/user/composer.md b/docs/user/composer.md index 8cb8d7805..65c39128b 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,9 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +You can send the first message with **New worktree** selected while branches are still loading. +T3 Code uses the repository's default base unless you choose a branch. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a9833f749..74d340eb8 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -5,6 +5,10 @@ import { CommandId, EnvironmentId, MessageId, + GitCommandError, + OrchestrationDispatchCommandError, + type VcsListRefsInput, + type VcsListRefsResult, NodeId, ORCHESTRATION_V2_WS_METHODS, PlanId, @@ -22,7 +26,10 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -34,6 +41,7 @@ import { } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; +import type { EnvironmentRpcFailure } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { v2Now, v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts"; import { @@ -72,6 +80,10 @@ const TARGET = new PrimaryConnectionTarget({ }); const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: { + readonly listRefs?: ( + input: VcsListRefsInput, + ) => Effect.Effect>; + readonly onLaunch?: () => Effect.Effect; readonly commands: OrchestrationV2Command[]; readonly projects: ProjectMutation[]; readonly launches?: OrchestrationV2ThreadLaunchInput[]; @@ -80,6 +92,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct readonly advertiseServerResolvedCommandContext?: boolean; }) { const client = { + [WS_METHODS.vcsListRefs]: input.listRefs ?? (() => Effect.never), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => Effect.sync(() => { input.commands.push(command); @@ -93,8 +106,9 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct return input.projection ?? v2Projection; }), [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) => - Effect.sync(() => { + Effect.gen(function* () { input.launches?.push(launchInput); + yield* input.onLaunch?.() ?? Effect.void; return { threadId: launchInput.threadId ?? v2ThreadId, projection: input.projection ?? v2Projection, @@ -970,3 +984,310 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); }); + +const automaticWorktreeTurn = { + commandId: CommandId.make("captured-command"), + threadId: ThreadId.make("captured-thread"), + message: { + messageId: MessageId.make("captured-message"), + role: "user" as const, + text: "Start the task", + attachments: [], + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + bootstrap: { + createThread: { + projectId: ProjectId.make("captured-project"), + title: "Captured draft", + modelSelection: v2Projection.thread.modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(v2Now), + }, + prepareWorktree: { + projectCwd: "/workspace/project", + branch: "t3/captured-branch", + startFromOrigin: true, + }, + runSetupScript: true, + }, +}; + +const automaticWorktreeLaunch = { + commandId: automaticWorktreeTurn.commandId, + creationSource: "web" as const, + threadId: automaticWorktreeTurn.threadId, + projectId: automaticWorktreeTurn.bootstrap.createThread.projectId, + title: automaticWorktreeTurn.bootstrap.createThread.title, + generateTitle: false, + modelSelection: automaticWorktreeTurn.bootstrap.createThread.modelSelection, + runtimeMode: automaticWorktreeTurn.runtimeMode, + interactionMode: automaticWorktreeTurn.interactionMode, + workspaceStrategy: { + type: "worktree" as const, + branch: automaticWorktreeTurn.bootstrap.prepareWorktree.branch, + startFromOrigin: true, + }, + initialMessage: { + messageId: automaticWorktreeTurn.message.messageId, + text: automaticWorktreeTurn.message.text, + attachments: [], + }, +}; + +const refsResult = (refs: VcsListRefsResult["refs"], isRepo = true): VcsListRefsResult => ({ + refs, + isRepo, + hasPrimaryRemote: refs.some((ref) => ref.isRemote === true), + nextCursor: null, + totalCount: refs.length, +}); + +const withBaseRef = (baseRef: string) => ({ + ...automaticWorktreeLaunch, + workspaceStrategy: { ...automaticWorktreeLaunch.workspaceStrategy, baseRef }, +}); + +describe("v2 worktree start compatibility", () => { + it.effect("waits for one unfiltered default-ref lookup before launching on older servers", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const firstOperation = yield* Deferred.make<"refs" | "launch">(); + const refs = yield* Deferred.make(); + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + onLaunch: () => Deferred.succeed(firstOperation, "launch").pipe(Effect.asVoid), + listRefs: (input) => + Effect.gen(function* () { + lookups.push(input); + yield* Deferred.succeed(firstOperation, "refs"); + return yield* Deferred.await(refs); + }), + }); + const start = yield* startThreadTurn(automaticWorktreeTurn).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + expect(yield* Deferred.await(firstOperation)).toBe("refs"); + expect(lookups).toEqual([{ cwd: "/workspace/project", limit: 100 }]); + expect(launches).toEqual([]); + yield* Deferred.succeed( + refs, + refsResult([ + { name: "feature/current", current: true, isDefault: false, worktreePath: null }, + { + name: "origin/develop", + current: false, + isDefault: true, + isRemote: true, + worktreePath: null, + }, + ]), + ); + expect(yield* Fiber.join(start)).toMatchObject({ threadId: automaticWorktreeTurn.threadId }); + expect(launches).toEqual([withBaseRef("origin/develop")]); + expect(commands).toEqual([]); + expect(automaticWorktreeTurn.bootstrap.prepareWorktree).not.toHaveProperty("baseBranch"); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER), Effect.scoped), + ); + + it.effect("passes automatic intent directly to capable servers without the client hint", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: (input) => + Effect.sync(() => { + lookups.push(input); + return refsResult([]); + }), + }); + yield* startThreadTurn({ ...automaticWorktreeTurn, serverResolvesWorktreeBase: true }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + expect(lookups).toEqual([]); + expect(launches).toEqual([automaticWorktreeLaunch]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect( + "keeps independent command and thread identities for automatic multi-model launches", + () => + Effect.gen(function* () { + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const supervisor = yield* makeSupervisor({ + commands: [], + projects: [], + launches, + listRefs: (input) => + Effect.sync(() => { + lookups.push(input); + return refsResult([]); + }), + }); + for (const suffix of ["one", "two"]) { + yield* startThreadTurn({ + ...automaticWorktreeTurn, + commandId: CommandId.make(`multi-model-command-${suffix}`), + threadId: ThreadId.make(`multi-model-thread-${suffix}`), + serverResolvesWorktreeBase: true, + message: { + ...automaticWorktreeTurn.message, + messageId: MessageId.make(`multi-model-message-${suffix}`), + }, + bootstrap: { + ...automaticWorktreeTurn.bootstrap, + prepareWorktree: { + projectCwd: "/workspace/project", + requireWorktree: true, + startFromOrigin: true, + }, + }, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + } + expect(lookups).toEqual([]); + expect(launches).toEqual( + ["one", "two"].map((suffix) => ({ + ...automaticWorktreeLaunch, + commandId: CommandId.make(`multi-model-command-${suffix}`), + threadId: ThreadId.make(`multi-model-thread-${suffix}`), + initialMessage: { + ...automaticWorktreeLaunch.initialMessage, + messageId: MessageId.make(`multi-model-message-${suffix}`), + }, + workspaceStrategy: { type: "worktree", startFromOrigin: true }, + })), + ); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("preserves an explicit base and skips lookup for current-checkout launches", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: (input) => + Effect.sync(() => { + lookups.push(input); + return refsResult([]); + }), + }); + yield* startThreadTurn({ + ...automaticWorktreeTurn, + serverResolvesWorktreeBase: false, + bootstrap: { + ...automaticWorktreeTurn.bootstrap, + prepareWorktree: { + ...automaticWorktreeTurn.bootstrap.prepareWorktree, + baseBranch: "upstream/release", + }, + }, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* startThreadTurn({ + ...automaticWorktreeTurn, + serverResolvesWorktreeBase: false, + bootstrap: { createThread: automaticWorktreeTurn.bootstrap.createThread }, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(lookups).toEqual([]); + expect(launches).toEqual([ + withBaseRef("upstream/release"), + { ...automaticWorktreeLaunch, workspaceStrategy: { type: "root" } }, + ]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("uses the current local branch when an older server reports no default", () => + Effect.gen(function* () { + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const supervisor = yield* makeSupervisor({ + commands: [], + projects: [], + launches, + listRefs: () => + Effect.succeed( + refsResult([ + { name: "feature/local", current: true, isDefault: false, worktreePath: null }, + ]), + ), + }); + yield* startThreadTurn({ ...automaticWorktreeTurn, serverResolvesWorktreeBase: false }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + expect(launches).toEqual([withBaseRef("feature/local")]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("fails clearly without launching when no valid automatic base exists", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + for (const isRepo of [true, false]) { + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: () => Effect.succeed(refsResult([], isRepo)), + }); + const failure = yield* startThreadTurn(automaticWorktreeTurn).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + expect(failure).toBeInstanceOf(OrchestrationDispatchCommandError); + expect(failure.message).toContain("base branch"); + } + expect(launches).toEqual([]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("propagates lookup failure without launching or retrying", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + let lookupCount = 0; + const refsFailure = new GitCommandError({ + operation: "listRefs", + command: "git", + cwd: "/workspace/project", + detail: "Repository unavailable", + }); + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: () => + Effect.suspend(() => { + lookupCount += 1; + return Effect.fail(refsFailure); + }), + }); + const failure = yield* startThreadTurn(automaticWorktreeTurn).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + expect(failure).toBe(refsFailure); + expect(lookupCount).toBe(1); + expect(launches).toEqual([]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); +}); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index bedf552f9..d01875f8b 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -6,6 +6,7 @@ import { CheckpointScopeId, ORCHESTRATION_V2_WS_METHODS, OrchestrationV2CheckpointUnavailableError, + OrchestrationDispatchCommandError, WS_METHODS, type ChatAttachment, type MessageId, @@ -26,6 +27,7 @@ import { type ThreadEnvMode, type UploadChatAttachment, } from "@t3tools/contracts"; +import { resolveDefaultWorktreeBaseBranch } from "@t3tools/shared/git"; import { modelSelectionCommandType } from "@t3tools/shared/model"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Crypto from "effect/Crypto"; @@ -155,7 +157,7 @@ interface StartThreadBootstrap { /** V2 worktree launches always fail rather than falling back to the project checkout. */ readonly requireWorktree?: boolean; readonly projectCwd: string; - readonly baseBranch: string; + readonly baseBranch?: string; readonly branch?: string; readonly startFromOrigin?: boolean; }; @@ -163,6 +165,8 @@ interface StartThreadBootstrap { } export interface StartThreadTurnInput extends ThreadCommandInput { + /** Client-only capability hint; never sent in the launch payload. */ + readonly serverResolvesWorktreeBase?: boolean; readonly manualContinuationOfRunId?: RunId; readonly message: { readonly messageId: MessageId; @@ -637,7 +641,27 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( attachments, ); const bootstrap = input.bootstrap?.createThread; - const prepareWorktree = input.bootstrap?.prepareWorktree; + let prepareWorktree = input.bootstrap?.prepareWorktree; + if ( + prepareWorktree !== undefined && + prepareWorktree.baseBranch === undefined && + input.serverResolvesWorktreeBase !== true + ) { + const result = yield* request(WS_METHODS.vcsListRefs, { + cwd: prepareWorktree.projectCwd, + limit: 100, + }); + const baseBranch = result.isRepo ? resolveDefaultWorktreeBaseBranch(result.refs) : null; + if (baseBranch === null) { + return yield* Effect.fail( + new OrchestrationDispatchCommandError({ + message: + "Unable to select a base branch for the new worktree. Choose a base ref and retry.", + }), + ); + } + prepareWorktree = { ...prepareWorktree, baseBranch }; + } if (bootstrap !== undefined || prepareWorktree !== undefined) { const existingProjection = bootstrap === undefined ? yield* getProjection(input.threadId) : null; @@ -646,7 +670,9 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( prepareWorktree !== undefined ? { type: "worktree" as const, - baseRef: prepareWorktree.baseBranch, + ...(prepareWorktree.baseBranch === undefined + ? {} + : { baseRef: prepareWorktree.baseBranch }), ...(prepareWorktree.branch === undefined ? {} : { branch: prepareWorktree.branch }), ...(prepareWorktree.startFromOrigin === undefined ? {} diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3f624adf6..6522bfb87 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -14,6 +14,16 @@ const descriptor = { } as const; describe("ExecutionEnvironmentDescriptor", () => { + it("preserves automatic worktree base capability while accepting older servers", () => { + expect(decodeDescriptor(descriptor).capabilities.worktreeDefaultBase).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, worktreeDefaultBase: true }, + }).capabilities.worktreeDefaultBase, + ).toBe(true); + }); + it("requires an advertised required-worktree bootstrap capability", () => { expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined(); expect( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9a31f0888..c1f428a4b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -91,6 +91,7 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), + worktreeDefaultBase: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ attachmentUploads: Schema.optionalKey(Schema.Boolean), /** Uploaded files may accompany question answers. */ diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 601597fb5..a4e59538b 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -22,6 +22,7 @@ import { TurnItemId, } from "./index.ts"; import { + OrchestrationV2ThreadLaunchWorkspaceStrategy, OrchestrationV2Checkpoint, OrchestrationV2CheckpointScope, OrchestrationV2Command, @@ -1248,3 +1249,22 @@ describe("limit recovery choice updates", () => { expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice }); }); }); + +describe("worktree launch base", () => { + const decode = Schema.decodeUnknownSync(OrchestrationV2ThreadLaunchWorkspaceStrategy); + + it("round-trips an omitted base for automatic server selection", () => { + const input = { type: "worktree", branch: "feature", startFromOrigin: true }; + expect(Schema.encodeSync(OrchestrationV2ThreadLaunchWorkspaceStrategy)(decode(input))).toEqual( + input, + ); + }); + + it("preserves explicit bases and rejects blank bases", () => { + expect(decode({ type: "worktree", baseRef: "release/stable" })).toEqual({ + type: "worktree", + baseRef: "release/stable", + }); + expect(() => decode({ type: "worktree", baseRef: " " })).toThrow(); + }); +}); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 220fec2b1..bf766e945 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -520,7 +520,7 @@ export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([ }), Schema.Struct({ type: Schema.Literal("worktree"), - baseRef: TrimmedNonEmptyString, + baseRef: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), }), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 36e1b2a02..2a45d49a7 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -9,9 +9,44 @@ import { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig, + resolveDefaultWorktreeBaseBranch, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; +describe("resolveDefaultWorktreeBaseBranch", () => { + const current = { name: "feature/work", current: true, isDefault: false, isRemote: false }; + + it.each(["main", "develop", "origin/release/stable"])( + "prefers default %s over the current branch", + (name) => { + expect( + resolveDefaultWorktreeBaseBranch([ + current, + { name, current: false, isDefault: true, isRemote: name.startsWith("origin/") }, + ]), + ).toBe(name); + }, + ); + + it("uses the current local branch for a local-only repository", () => { + expect(resolveDefaultWorktreeBaseBranch([current])).toBe("feature/work"); + }); + + it("uses a known default in a detached repository", () => { + expect( + resolveDefaultWorktreeBaseBranch([ + { name: "origin/develop", current: false, isDefault: true, isRemote: true }, + ]), + ).toBe("origin/develop"); + }); + + it("does not invent a base for empty or detached local-only repositories", () => { + expect(resolveDefaultWorktreeBaseBranch([])).toBeNull(); + expect(resolveDefaultWorktreeBaseBranch([{ ...current, current: false }])).toBeNull(); + expect(resolveDefaultWorktreeBaseBranch([{ ...current, isRemote: true }])).toBeNull(); + }); +}); + describe("normalizeGitRemoteUrl", () => { it("canonicalizes equivalent GitHub remotes across protocol variants", () => { expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index d5866d32e..30a575fe8 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -12,6 +12,17 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; + +export function resolveDefaultWorktreeBaseBranch( + refs: ReadonlyArray>, +): string | null { + return ( + refs.find((ref) => ref.isDefault)?.name ?? + refs.find((ref) => ref.current && !ref.isRemote)?.name ?? + null + ); +} + // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly // that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads From bbc7d9008938664e4c7c9de4a8ee687d79b345d9 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 04:17:04 +0200 Subject: [PATCH 14/59] feat(conversations): add authenticated local conversation library --- apps/server/src/conversations/Service.test.ts | 29 + apps/server/src/conversations/Service.ts | 51 + apps/server/src/conversations/Store.cases.ts | 573 +++++++ apps/server/src/conversations/Store.test.ts | 6 + apps/server/src/conversations/Store.ts | 645 ++++++++ apps/server/src/conversations/http.test.ts | 221 +++ apps/server/src/conversations/http.ts | 129 ++ apps/server/src/conversations/open.cases.ts | 93 ++ apps/server/src/conversations/open.test.ts | 6 + apps/server/src/conversations/open.ts | 124 ++ apps/server/src/server.ts | 2 + .../ConversationLibraryPage.logic.test.ts | 106 ++ .../ConversationLibraryPage.logic.ts | 56 + .../ConversationLibraryPage.test.tsx | 26 + .../conversations/ConversationLibraryPage.tsx | 1324 +++++++++++++++++ .../src/components/sidebar/SidebarChrome.tsx | 11 +- .../sidebar/mainAppLocation.test.ts | 11 + .../src/components/sidebar/mainAppLocation.ts | 5 +- apps/web/src/routes/_chat.conversations.tsx | 7 + apps/web/src/state/conversations.ts | 90 ++ packages/client-runtime/package.json | 4 + .../src/conversations/http.test.ts | 192 +++ .../client-runtime/src/conversations/http.ts | 76 + .../src/conversations/import.test.ts | 172 +++ .../src/conversations/import.ts | 343 +++++ .../client-runtime/src/conversations/index.ts | 3 + .../src/conversations/model.cases.ts | 686 +++++++++ .../src/conversations/model.test.ts | 6 + .../client-runtime/src/conversations/model.ts | 491 ++++++ packages/contracts/package.json | 4 + .../contracts/src/conversationLibrary.test.ts | 134 ++ packages/contracts/src/conversationLibrary.ts | 419 ++++++ packages/contracts/src/environmentHttp.ts | 89 +- packages/shared/package.json | 4 + .../shared/src/conversationLibrary.cases.ts | 317 ++++ .../shared/src/conversationLibrary.test.ts | 6 + packages/shared/src/conversationLibrary.ts | 243 +++ 37 files changed, 6700 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/conversations/Service.test.ts create mode 100644 apps/server/src/conversations/Service.ts create mode 100644 apps/server/src/conversations/Store.cases.ts create mode 100644 apps/server/src/conversations/Store.test.ts create mode 100644 apps/server/src/conversations/Store.ts create mode 100644 apps/server/src/conversations/http.test.ts create mode 100644 apps/server/src/conversations/http.ts create mode 100644 apps/server/src/conversations/open.cases.ts create mode 100644 apps/server/src/conversations/open.test.ts create mode 100644 apps/server/src/conversations/open.ts create mode 100644 apps/web/src/components/conversations/ConversationLibraryPage.logic.test.ts create mode 100644 apps/web/src/components/conversations/ConversationLibraryPage.logic.ts create mode 100644 apps/web/src/components/conversations/ConversationLibraryPage.test.tsx create mode 100644 apps/web/src/components/conversations/ConversationLibraryPage.tsx create mode 100644 apps/web/src/components/sidebar/mainAppLocation.test.ts create mode 100644 apps/web/src/routes/_chat.conversations.tsx create mode 100644 apps/web/src/state/conversations.ts create mode 100644 packages/client-runtime/src/conversations/http.test.ts create mode 100644 packages/client-runtime/src/conversations/http.ts create mode 100644 packages/client-runtime/src/conversations/import.test.ts create mode 100644 packages/client-runtime/src/conversations/import.ts create mode 100644 packages/client-runtime/src/conversations/index.ts create mode 100644 packages/client-runtime/src/conversations/model.cases.ts create mode 100644 packages/client-runtime/src/conversations/model.test.ts create mode 100644 packages/client-runtime/src/conversations/model.ts create mode 100644 packages/contracts/src/conversationLibrary.test.ts create mode 100644 packages/contracts/src/conversationLibrary.ts create mode 100644 packages/shared/src/conversationLibrary.cases.ts create mode 100644 packages/shared/src/conversationLibrary.test.ts create mode 100644 packages/shared/src/conversationLibrary.ts diff --git a/apps/server/src/conversations/Service.test.ts b/apps/server/src/conversations/Service.test.ts new file mode 100644 index 000000000..afa48ce79 --- /dev/null +++ b/apps/server/src/conversations/Service.test.ts @@ -0,0 +1,29 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as ServerConfig from "../config.ts"; +import * as ConversationLibrary from "./Service.ts"; + +const TestLayer = ConversationLibrary.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-library-service-" })), + Layer.provideMerge(NodeServices.layer), +); + +it.effect("direct library service rejects writes before creating storage and remains readable", () => + Effect.scoped(Effect.gen(function* () { + const library = yield* ConversationLibrary.ConversationLibrary; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const failure = yield* library.execute({ + kind: "createAccount", label: "Synthetic", workspace: "Task fixture", + }, false).pipe(Effect.flip); + expect(failure.code).toBe("forbidden"); + expect(yield* fs.exists(path.join(config.stateDir, "conversation-library"))).toBe(false); + expect(yield* library.execute({ kind: "accounts" }, false)).toEqual({ kind: "accounts", accounts: [] }); + expect(yield* fs.exists(path.join(config.stateDir, "conversation-library"))).toBe(false); + }).pipe(Effect.provide(TestLayer))), +); diff --git a/apps/server/src/conversations/Service.ts b/apps/server/src/conversations/Service.ts new file mode 100644 index 000000000..9ed42e737 --- /dev/null +++ b/apps/server/src/conversations/Service.ts @@ -0,0 +1,51 @@ +import type { LibraryReply, LibraryRequest } from "@t3tools/contracts/conversationLibrary"; +import { ConversationLibraryError, libraryRequestMutates } from "@t3tools/shared/conversationLibrary"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import { openConversationLibrary } from "./open.ts"; + +export class ConversationLibrary extends Context.Service< + ConversationLibrary, + { + readonly execute: ( + request: LibraryRequest, + canWrite: boolean, + ) => Effect.Effect; + } +>()("t3/conversations/ConversationLibrary") {} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return ConversationLibrary.of({ + execute: (request, canWrite) => Effect.gen(function* () { + const mutates = libraryRequestMutates(request); + if (mutates && !canWrite) { + return yield* Effect.fail(new ConversationLibraryError( + "forbidden", "The conversation library operation is not allowed.", + )); + } + const now = yield* Clock.currentTimeMillis; + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => openConversationLibrary(config.stateDir, mutates, now), + catch: (cause) => cause instanceof ConversationLibraryError + ? cause + : new ConversationLibraryError("storage", "The conversation library could not be opened."), + }), + (store) => Effect.try({ + try: () => store.execute(request, canWrite), + catch: (cause) => cause instanceof ConversationLibraryError + ? cause + : new ConversationLibraryError("storage", "The conversation library could not be read or changed."), + }), + (store) => Effect.sync(() => store.close()), + ); + }), + }); +}); + +export const layer = Layer.effect(ConversationLibrary, make); diff --git a/apps/server/src/conversations/Store.cases.ts b/apps/server/src/conversations/Store.cases.ts new file mode 100644 index 000000000..a1c8ae32e --- /dev/null +++ b/apps/server/src/conversations/Store.cases.ts @@ -0,0 +1,573 @@ +import * as NodeAssert from "node:assert/strict"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Persistence coverage needs a real Node SQLite file on a temporary path to verify reopen and read-only behavior. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The synchronous persistence fixture needs native path semantics outside an Effect runtime. +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import type { + ExportConversation, + LibraryReply, + LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; +import { ConversationLibraryStore, type LibraryDatabase } from "./Store.ts"; + +function sample(id = "shared-chat", update = 100, answer = "Original answer"): ExportConversation { + return { + id, + title: `Conversation ${id}`, + update_time: update, + current_node: "a", + mapping: { + root: { parent: null }, + u: { + parent: "root", + message: { author: { role: "user" }, content: { parts: ["Sample question"] } }, + }, + a: { parent: "u", message: { author: { role: "assistant" }, content: { parts: [answer] } } }, + }, + }; +} + +function expectKind( + reply: LibraryReply, + kind: K, +): Extract { + NodeAssert.equal(reply.kind, kind); + return reply as Extract; +} + +function withStore( + run: (store: ConversationLibraryStore, db: NodeSqlite.DatabaseSync) => void, +): void { + const db = new NodeSqlite.DatabaseSync(":memory:"); + try { + const store = new ConversationLibraryStore(db, { + initialize: true, + clock: () => 1_800_000_000_000, + }); + run(store, db); + } finally { + db.close(); + } +} + +function account( + store: ConversationLibraryStore, + label = "Account A", + workspace = "Personal", +): string { + return expectKind(store.execute({ kind: "createAccount", label, workspace }, true), "account") + .account.id; +} + +function imported(store: ConversationLibraryStore, accountId: string, conversations = [sample()]) { + return expectKind(store.execute({ kind: "import", accountId, conversations }, true), "imported"); +} + +function rows( + store: ConversationLibraryStore, + input: Extract = { kind: "list" }, +) { + return expectKind(store.execute(input, false), "list"); +} + +export const conversationStoreCases: readonly { + readonly name: string; + readonly run: () => void; +}[] = [ + { + name: "stores supplied conversations and never advertises live capture", + run: () => + withStore((store) => { + const id = account(store); + NodeAssert.equal(imported(store, id).inserted, 1); + const found = rows(store).rows; + NodeAssert.equal(found.length, 1); + NodeAssert.equal(found[0]!.unread, true); + const hello = expectKind(store.execute({ kind: "hello" }, false), "hello"); + NodeAssert.equal(hello.capture, "not-enabled"); + NodeAssert.equal(hello.canWrite, false); + const detail = expectKind( + store.execute({ kind: "detail", key: found[0]!.key }, false), + "detail", + ); + NodeAssert.deepEqual( + detail.messages.map((message) => message.text), + ["Sample question", "Original answer"], + ); + }), + }, + { + name: "isolates identical conversation and node IDs across account/workspace bindings", + run: () => + withStore((store) => { + const a = account(store, "A", "Personal"), + b = account(store, "A", "Work"); + imported(store, a); + imported(store, b); + const first = rows(store, { kind: "list", accountId: a }).rows[0]!; + const second = rows(store, { kind: "list", accountId: b }).rows[0]!; + NodeAssert.notEqual(first.key, second.key); + NodeAssert.notEqual(first.snapshotId, second.snapshotId); + NodeAssert.throws( + () => + store.execute({ kind: "detail", key: first.key, snapshotId: second.snapshotId }, false), + /does not exist/, + ); + NodeAssert.equal(rows(store).rows.length, 2); + }), + }, + { + name: "duplicate imports are idempotent and do not create unread changes", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id); + const first = rows(store).rows[0]!; + store.execute({ kind: "update", key: first.key, readThrough: first.revision }, true); + const before = rows(store).revision; + NodeAssert.equal(imported(store, id).duplicates, 1); + const after = rows(store); + NodeAssert.equal(after.revision, before); + NodeAssert.equal(after.rows[0]!.unread, false); + }), + }, + { + name: "old read acknowledgements cannot hide newer admitted content", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id); + const first = rows(store).rows[0]!; + imported(store, id, [sample("shared-chat", 200, "New answer")]); + store.execute({ kind: "update", key: first.key, readThrough: first.revision }, true); + NodeAssert.equal(rows(store).rows[0]!.unread, true); + NodeAssert.throws( + () => store.execute({ kind: "update", key: first.key, readThrough: 99999 }, true), + /future revision/, + ); + }), + }, + { + name: "pinning and archiving do not masquerade as new output", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id); + const first = rows(store).rows[0]!; + store.execute( + { kind: "update", key: first.key, readThrough: first.revision, pinned: true }, + true, + ); + const pinned = rows(store, { kind: "list", view: "pinned" }).rows[0]!; + NodeAssert.equal(pinned.revision, first.revision); + NodeAssert.equal(pinned.unread, false); + store.execute({ kind: "update", key: first.key, archived: true }, true); + NodeAssert.equal(rows(store).rows.length, 0); + NodeAssert.equal(rows(store, { kind: "list", view: "archived" }).rows.length, 1); + imported(store, id); + NodeAssert.equal(rows(store).rows.length, 0); + store.execute({ kind: "update", key: first.key, archived: false }, true); + NodeAssert.equal(rows(store).rows.length, 1); + }), + }, + { + name: "retains late older snapshots without overwriting the selected transcript", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id, [sample("shared-chat", 200, "Later")]); + const first = rows(store).rows[0]!; + const result = imported(store, id, [sample("shared-chat", 100, "Earlier")]); + NodeAssert.equal(result.older, 1); + const detail = expectKind( + store.execute({ kind: "detail", key: first.key }, false), + "detail", + ); + NodeAssert.equal(detail.snapshotCount, 2); + NodeAssert.equal(detail.messages.at(-1)!.text, "Later"); + NodeAssert.equal(detail.conversation.revision, first.revision); + const old = detail.snapshots.find((snapshot) => snapshot.id !== detail.snapshotId)!; + const oldDetail = expectKind( + store.execute({ kind: "detail", key: first.key, snapshotId: old.id }, false), + "detail", + ); + NodeAssert.equal(oldDetail.messages.at(-1)!.text, "Earlier"); + NodeAssert.equal(oldDetail.readThrough, 0); + }), + }, + { + name: "retains same-time conflicting snapshots until an explicit selection", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id); + const first = rows(store).rows[0]!; + NodeAssert.equal( + imported(store, id, [sample("shared-chat", 100, "Different")]).conflicts, + 1, + ); + const current = rows(store).rows[0]!; + NodeAssert.equal(current.conflicts, true); + const detail = expectKind( + store.execute({ kind: "detail", key: current.key }, false), + "detail", + ); + NodeAssert.equal(detail.readThrough, 0); + const conflict = detail.snapshots.find((snapshot) => snapshot.id !== detail.snapshotId)!; + NodeAssert.throws( + () => + store.execute( + { + kind: "selectSnapshot", + key: first.key, + snapshotId: conflict.id, + expectedRevision: first.revision, + }, + true, + ), + /changed/, + ); + store.execute( + { + kind: "selectSnapshot", + key: first.key, + snapshotId: conflict.id, + expectedRevision: current.revision, + }, + true, + ); + const chosen = expectKind( + store.execute({ kind: "detail", key: first.key }, false), + "detail", + ); + NodeAssert.equal(chosen.messages.at(-1)!.text, "Different"); + NodeAssert.equal(chosen.conversation.conflicts, false); + }), + }, + { + name: "missing update timestamps are not replaced with creation or import time", + run: () => + withStore((store) => { + const id = account(store); + const missing = { ...sample(), update_time: null, create_time: 500 }; + imported(store, id, [missing]); + NodeAssert.equal(rows(store).rows[0]!.sourceUpdatedAt, null); + NodeAssert.equal( + imported(store, id, [sample("shared-chat", 600, "Known timestamp")]).conflicts, + 1, + ); + NodeAssert.equal(rows(store).rows[0]!.sourceUpdatedAt, null); + }), + }, + { + name: "rejects malformed batches before admitting their valid prefix", + run: () => + withStore((store) => { + const id = account(store); + const before = rows(store).revision; + NodeAssert.throws( + () => + imported(store, id, [ + sample("good"), + { ...sample("cycle"), mapping: { a: { parent: "a" } } }, + ]), + /cycle/, + ); + NodeAssert.equal(rows(store).rows.length, 0); + NodeAssert.equal(rows(store).revision, before); + }), + }, + { + name: "rolls back rows, snapshots, search and revision on an injected SQLite write failure", + run: () => { + const db = new NodeSqlite.DatabaseSync(":memory:"); + let fail = false, + writes = 0; + const driver: LibraryDatabase = { + exec: (sql) => db.exec(sql), + close: () => db.close(), + prepare: (sql) => { + const statement = db.prepare(sql); + return { + get: (...values) => statement.get(...values), + all: (...values) => statement.all(...values), + run: (...values) => { + if (fail && sql.startsWith("INSERT INTO nodes") && ++writes === 2) + throw new Error("injected write failure"); + return statement.run(...values); + }, + }; + }, + }; + try { + const store = new ConversationLibraryStore(driver, { initialize: true }); + const id = account(store); + const before = rows(store).revision; + fail = true; + NodeAssert.throws(() => imported(store, id), /injected/); + NodeAssert.equal(rows(store).rows.length, 0); + NodeAssert.equal(rows(store).revision, before); + for (const table of ["snapshots", "nodes", "library_search"]) + NodeAssert.equal(db.prepare(`SELECT count(*) AS n FROM ${table}`).get()!.n, 0); + } finally { + db.close(); + } + }, + }, + { + name: "pages the full catalog and rejects stale or differently scoped cursors", + run: () => + withStore((store) => { + const id = account(store); + imported( + store, + id, + Array.from({ length: 61 }, (_, index) => sample(`chat-${index}`)), + ); + const first = rows(store); + NodeAssert.equal(first.rows.length, 50); + NodeAssert.ok(first.cursor); + const second = rows(store, { kind: "list", cursor: first.cursor }); + NodeAssert.equal(second.rows.length, 11); + NodeAssert.equal(second.cursor, null); + NodeAssert.equal(new Set([...first.rows, ...second.rows].map((row) => row.key)).size, 61); + NodeAssert.throws( + () => rows(store, { kind: "list", view: "unread", cursor: first.cursor! }), + /list changed/, + ); + store.execute({ kind: "update", key: first.rows[0]!.key, pinned: true }, true); + NodeAssert.throws( + () => rows(store, { kind: "list", cursor: first.cursor! }), + /list changed/, + ); + NodeAssert.throws( + () => rows(store, { kind: "list", cursor: "garbage" }), + /cursor is invalid/, + ); + }), + }, + { + name: "pages message bodies on a fixed branch with no gaps", + run: () => + withStore((store) => { + const id = account(store); + const mapping = Object.fromEntries( + Array.from({ length: 135 }, (_, index) => [ + String(index), + { + parent: index === 0 ? null : String(index - 1), + message: { + author: { role: index % 2 ? "assistant" : "user" }, + content: { parts: [`message ${index}`] }, + }, + }, + ]), + ); + imported(store, id, [{ ...sample(), mapping, current_node: "134" }]); + const key = rows(store).rows[0]!.key; + const initial = expectKind(store.execute({ kind: "detail", key }, false), "detail"); + NodeAssert.equal(initial.offset, 100); + NodeAssert.equal(initial.messages.length, 35); + const seen: string[] = []; + let start: number | null = 0; + while (start !== null) { + const detail: Extract = expectKind( + store.execute({ kind: "detail", key, offset: start }, false), + "detail", + ); + seen.push(...detail.messages.map((entry) => entry.id)); + start = detail.nextOffset; + } + NodeAssert.equal(new Set(seen).size, 135); + NodeAssert.deepEqual( + seen, + Array.from({ length: 135 }, (_, index) => String(index)), + ); + }), + }, + { + name: "searches selected snapshot text and handles literal query syntax safely", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id, [ + sample("one", 100, "Distinctive answer"), + sample("two", 100, "Other text"), + ]); + NodeAssert.equal( + rows(store, { kind: "list", query: "Distinctive" }).rows[0]!.conversationId, + "one", + ); + NodeAssert.equal(rows(store, { kind: "list", query: "one" }).rows.length, 1); + for (const query of ['" OR *', '"', "(answer)", "not:syntax"]) { + const result = rows(store, { kind: "list", query }); + NodeAssert.ok(result.rows.length <= 2); + } + }), + }, + { + name: "search does not expose hidden exported text", + run: () => + withStore((store) => { + const id = account(store); + const original = sample(); + imported(store, id, [ + { + ...original, + mapping: { + ...original.mapping, + secret: { + message: { + author: { role: "system" }, + content: { parts: ["notindexabletoken"] }, + metadata: { is_visually_hidden_from_conversation: true }, + }, + }, + }, + }, + ]); + NodeAssert.equal(rows(store, { kind: "list", query: "notindexabletoken" }).rows.length, 0); + }), + }, + { + name: "removes only the selected local conversation and its retained data", + run: () => + withStore((store, db) => { + const a = account(store), + b = account(store, "B"); + imported(store, a); + imported(store, b); + const first = rows(store, { kind: "list", accountId: a }).rows[0]!; + imported(store, a, [sample("shared-chat", 200, "Newer")]); + NodeAssert.throws( + () => + store.execute( + { kind: "remove", key: first.key, expectedRevision: first.revision }, + true, + ), + /changed/, + ); + const current = rows(store, { kind: "list", accountId: a }).rows[0]!; + store.execute( + { kind: "remove", key: current.key, expectedRevision: current.revision }, + true, + ); + NodeAssert.equal(rows(store).rows.length, 1); + NodeAssert.equal(rows(store).rows[0]!.accountId, b); + NodeAssert.equal( + db + .prepare("SELECT count(*) AS n FROM snapshots WHERE conversation_key = ?") + .get(first.key)!.n, + 0, + ); + NodeAssert.equal( + db.prepare("SELECT count(*) AS n FROM library_search WHERE key = ?").get(first.key)!.n, + 0, + ); + }), + }, + { + name: "rejects every mutation for read-only callers", + run: () => + withStore((store) => { + const id = account(store); + imported(store, id); + const first = rows(store).rows[0]!; + const writes: LibraryRequest[] = [ + { kind: "createAccount", label: "B", workspace: "Personal" }, + { kind: "import", accountId: id, conversations: [sample("other")] }, + { kind: "update", key: first.key, pinned: true }, + { + kind: "selectSnapshot", + key: first.key, + snapshotId: first.snapshotId, + expectedRevision: first.revision, + }, + { kind: "remove", key: first.key, expectedRevision: first.revision }, + ]; + const before = rows(store).revision; + for (const request of writes) + NodeAssert.throws(() => store.execute(request, false), /cannot change/); + NodeAssert.equal(rows(store).revision, before); + NodeAssert.equal(rows(store).rows.length, 1); + }), + }, + { + name: "future database versions fail closed without rewriting them", + run: () => { + const db = new NodeSqlite.DatabaseSync(":memory:"); + try { + db.exec("PRAGMA user_version = 99"); + NodeAssert.throws( + () => new ConversationLibraryStore(db, { initialize: true }), + /version is not supported/, + ); + NodeAssert.equal(db.prepare("PRAGMA user_version").get()!.user_version, 99); + } finally { + db.close(); + } + }, + }, + { + name: "refuses to initialize an unrelated unversioned database", + run: () => { + const db = new NodeSqlite.DatabaseSync(":memory:"); + try { + db.exec("CREATE TABLE unrelated (value TEXT)"); + NodeAssert.throws( + () => new ConversationLibraryStore(db, { initialize: true }), + /unrecognized database/, + ); + NodeAssert.equal(db.prepare("SELECT count(*) AS n FROM unrelated").get()!.n, 0); + } finally { + db.close(); + } + }, + }, + { + name: "persists across reopen and supports read-only SQLite connections", + run: () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-library-test-")); + const path = NodePath.join(directory, "library.sqlite"); + try { + const db = new NodeSqlite.DatabaseSync(path); + try { + const store = new ConversationLibraryStore(db, { initialize: true }); + imported(store, account(store)); + } finally { + db.close(); + } + const read = new NodeSqlite.DatabaseSync(path, { readOnly: true }); + try { + const store = new ConversationLibraryStore(read); + NodeAssert.equal(rows(store).rows.length, 1); + } finally { + read.close(); + } + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } + }, + }, + { + name: "retention limits reject new snapshots without deleting retained versions", + run: () => + withStore((store) => { + const id = account(store); + for (let version = 1; version <= 250; version++) + imported(store, id, [sample("many", version, `version ${version}`)]); + const key = rows(store).rows[0]!.key; + const before = rows(store).revision; + NodeAssert.throws( + () => imported(store, id, [sample("many", 251, "too many")]), + /retention limit/, + ); + const detail = expectKind(store.execute({ kind: "detail", key }, false), "detail"); + NodeAssert.equal(detail.snapshotCount, 250); + NodeAssert.equal(rows(store).revision, before); + NodeAssert.equal(detail.messages.at(-1)!.text, "version 250"); + }), + }, +]; diff --git a/apps/server/src/conversations/Store.test.ts b/apps/server/src/conversations/Store.test.ts new file mode 100644 index 000000000..d5be11106 --- /dev/null +++ b/apps/server/src/conversations/Store.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vite-plus/test"; +import { conversationStoreCases } from "./Store.cases.ts"; + +describe("conversation library store", () => { + for (const test of conversationStoreCases) it(test.name, test.run); +}); diff --git a/apps/server/src/conversations/Store.ts b/apps/server/src/conversations/Store.ts new file mode 100644 index 000000000..6678ed978 --- /dev/null +++ b/apps/server/src/conversations/Store.ts @@ -0,0 +1,645 @@ +import * as NodeCrypto from "node:crypto"; +import { + CONVERSATION_LIBRARY_PROTOCOL, + LIBRARY_PAGE_SIZE, + type LibraryAccount, + type LibraryDetail, + type LibraryNode, + type LibraryReply, + type LibraryRequest, + type LibrarySnapshot, + type LibrarySummary, +} from "@t3tools/contracts/conversationLibrary"; +import { + ConversationLibraryError, + boundedLibraryString, + libraryRequestMutates, + normalizeConversationExport, +} from "@t3tools/shared/conversationLibrary"; + +type SqlValue = string | number | null; +type Row = Record; + +export interface LibraryDatabase { + exec(sql: string): unknown; + prepare(sql: string): { + get(...values: SqlValue[]): Row | undefined; + all(...values: SqlValue[]): Row[]; + run(...values: SqlValue[]): unknown; + }; + close(): void; +} + +const MAX_LIBRARY_BYTES = 512 * 1024 * 1024; +const MAX_LIBRARY_CONVERSATIONS = 50_000; +const MAX_SNAPSHOTS_PER_CONVERSATION = 250; + +function text(row: Row, key: string): string { + const value = row[key]; + if (typeof value !== "string") { + throw new ConversationLibraryError("storage", "The library contains an invalid text field."); + } + return value; +} + +function number(row: Row, key: string): number { + const value = row[key]; + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new ConversationLibraryError("storage", "The library contains an invalid numeric field."); + } + return value; +} + +function nullableNumber(row: Row, key: string): number | null { + return row[key] === null ? null : number(row, key); +} + +function nullableText(row: Row, key: string): string | null { + return row[key] === null ? null : text(row, key); +} + +function digest(value: unknown): string { + return NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function offset(value: number | undefined): number { + if (value === undefined) return 0; + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000) { + throw new ConversationLibraryError("invalid", "The requested page offset is invalid."); + } + return value; +} + +const summarySelect = `SELECT c.*, s.source_updated_at, s.imported_at, + s.message_count, s.warning_count + FROM conversations c LEFT JOIN snapshots s ON s.id = c.selected_snapshot + AND s.conversation_key = c.key`; + +function summary(row: Row): LibrarySummary { + return { + key: text(row, "key"), + accountId: text(row, "account_id"), + conversationId: text(row, "source_id"), + title: text(row, "title"), + snapshotId: text(row, "selected_snapshot"), + sourceUpdatedAt: nullableNumber(row, "source_updated_at"), + importedAt: number(row, "imported_at"), + revision: number(row, "change_revision"), + unread: number(row, "read_revision") < number(row, "change_revision"), + pinned: number(row, "pinned") === 1, + archived: number(row, "archived") === 1, + attention: number(row, "attention") === 1, + conflicts: number(row, "conflicts") === 1, + messageCount: number(row, "message_count"), + warningCount: number(row, "warning_count"), + }; +} + +function node(row: Row): LibraryNode { + return { + id: text(row, "node_id"), + parentId: nullableText(row, "parent_id"), + messageId: nullableText(row, "message_id"), + role: nullableText(row, "role"), + text: text(row, "text"), + createdAt: nullableNumber(row, "created_at"), + hidden: number(row, "hidden") === 1, + unsupportedParts: number(row, "unsupported_parts"), + }; +} + +/** Each mutation and its catalog revision commit together; replies follow COMMIT. */ +export class ConversationLibraryStore { + private readonly db: LibraryDatabase; + private readonly clock: () => number; + + constructor(db: LibraryDatabase, options: { initialize?: boolean; clock?: () => number } = {}) { + this.db = db; + this.clock = options.clock ?? Date.now; + db.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;"); + const version = number(this.one("PRAGMA user_version"), "user_version"); + if (version === 0 && options.initialize) { + const existing = number( + this.one("SELECT count(*) AS count FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'"), + "count", + ); + if (existing !== 0) + throw new ConversationLibraryError( + "unsupported", + "An unrecognized database occupies the library path.", + ); + db.exec(`BEGIN IMMEDIATE; + CREATE TABLE meta (id INTEGER PRIMARY KEY CHECK(id = 1), revision INTEGER NOT NULL); + INSERT INTO meta VALUES (1, 0); + CREATE TABLE accounts (id TEXT PRIMARY KEY, label TEXT NOT NULL, workspace TEXT NOT NULL); + CREATE TABLE conversations ( + key TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), + source_id TEXT NOT NULL, title TEXT NOT NULL, selected_snapshot TEXT NOT NULL, + change_revision INTEGER NOT NULL, read_revision INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, + attention INTEGER NOT NULL DEFAULT 0, conflicts INTEGER NOT NULL DEFAULT 0, + UNIQUE(account_id, source_id) + ); + CREATE INDEX conversation_order ON conversations(change_revision DESC, key); + CREATE INDEX conversation_account ON conversations(account_id, change_revision DESC, key); + CREATE TABLE snapshots ( + id TEXT PRIMARY KEY, conversation_key TEXT NOT NULL REFERENCES conversations(key) ON DELETE CASCADE, + title TEXT NOT NULL, source_updated_at INTEGER, imported_at INTEGER NOT NULL, + introduced_revision INTEGER NOT NULL, current_node TEXT, warnings TEXT NOT NULL, + message_count INTEGER NOT NULL, warning_count INTEGER NOT NULL + ); + CREATE INDEX snapshot_conversation ON snapshots(conversation_key, introduced_revision DESC); + CREATE TABLE nodes ( + snapshot_id TEXT NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE, + node_id TEXT NOT NULL, parent_id TEXT, message_id TEXT, role TEXT, text TEXT NOT NULL, + created_at INTEGER, hidden INTEGER NOT NULL, unsupported_parts INTEGER NOT NULL, + PRIMARY KEY(snapshot_id, node_id) + ); + CREATE INDEX node_parent ON nodes(snapshot_id, parent_id); + CREATE VIRTUAL TABLE library_search USING fts5(key UNINDEXED, title, body); + PRAGMA user_version = 1; + COMMIT;`); + } else if (version !== 1) { + throw new ConversationLibraryError( + "unsupported", + "This library database version is not supported.", + ); + } + } + + close(): void { + this.db.close(); + } + + private one(sql: string, ...values: SqlValue[]): Row { + const row = this.db.prepare(sql).get(...values); + if (!row) + throw new ConversationLibraryError( + "not-found", + "The requested library record does not exist.", + ); + return row; + } + + private revision(): number { + return number(this.one("SELECT revision FROM meta WHERE id = 1"), "revision"); + } + + private advance(): number { + this.db.exec("UPDATE meta SET revision = revision + 1 WHERE id = 1"); + return this.revision(); + } + + private atomic(operation: () => A): A { + this.db.exec("BEGIN IMMEDIATE"); + try { + const result = operation(); + const pages = number(this.one("PRAGMA page_count"), "page_count"); + const free = number(this.one("PRAGMA freelist_count"), "freelist_count"); + const size = number(this.one("PRAGMA page_size"), "page_size"); + if ((pages - free) * size > MAX_LIBRARY_BYTES) { + throw new ConversationLibraryError( + "too-large", + "The library reached its 512 MiB admission limit. Remove unneeded local records before importing more.", + ); + } + this.db.exec("COMMIT"); + return result; + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + + private account(id: string): LibraryAccount { + const row = this.one("SELECT * FROM accounts WHERE id = ?", id); + return { id: text(row, "id"), label: text(row, "label"), workspace: text(row, "workspace") }; + } + + private conversation(key: string): LibrarySummary { + return summary(this.one(`${summarySelect} WHERE c.key = ?`, key)); + } + + private indexSnapshot(key: string, id: string, title: string): void { + const rows = this.db + .prepare("SELECT text FROM nodes WHERE snapshot_id = ? AND hidden = 0 ORDER BY node_id") + .all(id); + const body = rows.map((row) => text(row, "text")).join("\n"); + this.db.prepare("DELETE FROM library_search WHERE key = ?").run(key); + this.db + .prepare("INSERT INTO library_search (key, title, body) VALUES (?, ?, ?)") + .run(key, title, body); + } + + private importSnapshots(accountId: string, snapshots: readonly LibrarySnapshot[]): LibraryReply { + this.account(accountId); + return this.atomic(() => { + let inserted = 0, + duplicates = 0, + older = 0, + conflicts = 0; + const insertNode = this.db.prepare(`INSERT INTO nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); + for (const snapshot of snapshots) { + const key = digest(["chatgpt-export", accountId, snapshot.conversationId]); + const id = digest(["snapshot-v1", key, snapshot]); + if ( + this.db + .prepare("SELECT id FROM snapshots WHERE id = ? AND conversation_key = ?") + .get(id, key) + ) { + duplicates++; + continue; + } + const prior = this.db.prepare(`${summarySelect} WHERE c.key = ?`).get(key); + if (!prior) { + const count = number(this.one("SELECT count(*) AS count FROM conversations"), "count"); + if (count >= MAX_LIBRARY_CONVERSATIONS) + throw new ConversationLibraryError( + "too-large", + "The library conversation limit has been reached.", + ); + this.db + .prepare(`INSERT INTO conversations + (key, account_id, source_id, title, selected_snapshot, change_revision) + VALUES (?, ?, ?, ?, ?, 0)`) + .run(key, accountId, snapshot.conversationId, snapshot.title, id); + } + const retained = this.one( + "SELECT count(*) AS count, max(source_updated_at) AS maximum FROM snapshots WHERE conversation_key = ?", + key, + ); + if (number(retained, "count") >= MAX_SNAPSHOTS_PER_CONVERSATION) { + throw new ConversationLibraryError( + "too-large", + "A conversation reached its 250-snapshot retention limit. No retained history was discarded.", + ); + } + const revision = this.advance(); + const messageCount = snapshot.nodes.filter( + (entry) => entry.role !== null && !entry.hidden, + ).length; + this.db + .prepare(`INSERT INTO snapshots VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + id, + key, + snapshot.title, + snapshot.sourceUpdatedAt, + this.clock(), + revision, + snapshot.currentNodeId, + JSON.stringify(snapshot.warnings), + messageCount, + snapshot.warnings.length, + ); + for (const entry of snapshot.nodes) { + insertNode.run( + id, + entry.id, + entry.parentId, + entry.messageId, + entry.role, + entry.text, + entry.createdAt, + Number(entry.hidden), + entry.unsupportedParts, + ); + } + const maximum = nullableNumber(retained, "maximum"); + const advanceSelection = + !prior || + (snapshot.sourceUpdatedAt !== null && + maximum !== null && + nullableNumber(prior, "source_updated_at") !== null && + number(prior, "conflicts") === 0 && + snapshot.sourceUpdatedAt > maximum); + if (advanceSelection) { + this.db + .prepare( + `UPDATE conversations SET title = ?, selected_snapshot = ?, change_revision = ? WHERE key = ?`, + ) + .run(snapshot.title, id, revision, key); + this.indexSnapshot(key, id, snapshot.title); + } else if ( + snapshot.sourceUpdatedAt !== null && + maximum !== null && + snapshot.sourceUpdatedAt < maximum + ) { + older++; + } else { + conflicts++; + this.db + .prepare("UPDATE conversations SET conflicts = 1, change_revision = ? WHERE key = ?") + .run(revision, key); + } + inserted++; + } + return { + kind: "imported", + inserted, + duplicates, + older, + conflicts, + revision: this.revision(), + }; + }); + } + + private list(request: Extract): LibraryReply { + const revision = this.revision(); + const query = request.query?.trim() ?? ""; + if (query.length > 500) + throw new ConversationLibraryError( + "invalid", + "Use a search phrase of at most 500 characters.", + ); + const view = request.view ?? "all"; + const identity = digest([request.accountId ?? null, query, view]); + const clauses = [view === "archived" ? "c.archived = 1" : "c.archived = 0"]; + const values: SqlValue[] = []; + if (request.accountId !== undefined) { + this.account(request.accountId); + clauses.push("c.account_id = ?"); + values.push(request.accountId); + } + if (view === "unread") clauses.push("c.read_revision < c.change_revision"); + if (view === "pinned") clauses.push("c.pinned = 1"); + if (view === "attention") clauses.push("(c.attention = 1 OR c.conflicts = 1)"); + if (query) { + clauses.push("c.key IN (SELECT key FROM library_search WHERE library_search MATCH ?)"); + values.push(`"${query.replaceAll('"', '""')}"`); + } + if (request.cursor) { + const match = /^(\d+):(\d+):([a-f0-9]{64}):([a-f0-9]{64})$/.exec(request.cursor); + if ( + !match || + !Number.isSafeInteger(Number(match[1])) || + !Number.isSafeInteger(Number(match[2])) + ) { + throw new ConversationLibraryError("invalid", "The conversation cursor is invalid."); + } + if (Number(match[1]) !== revision || match[4] !== identity) { + throw new ConversationLibraryError( + "conflict", + "The conversation list changed. Refresh before loading another page.", + ); + } + clauses.push("(c.change_revision < ? OR (c.change_revision = ? AND c.key > ?))"); + values.push(Number(match[2]), Number(match[2]), match[3]!); + } + const found = this.db + .prepare(`${summarySelect} WHERE ${clauses.join(" AND ")} + ORDER BY c.change_revision DESC, c.key ASC LIMIT ?`) + .all(...values, LIBRARY_PAGE_SIZE + 1); + const rows = found.slice(0, LIBRARY_PAGE_SIZE).map(summary); + const last = rows.at(-1); + return { + kind: "list", + rows, + revision, + cursor: + found.length > LIBRARY_PAGE_SIZE && last + ? `${revision}:${last.revision}:${last.key}:${identity}` + : null, + }; + } + + private detail(request: Extract): LibraryDetail { + const conversation = this.conversation(request.key); + const snapshotId = request.snapshotId ?? conversation.snapshotId; + const snapshot = this.one( + "SELECT * FROM snapshots WHERE id = ? AND conversation_key = ?", + snapshotId, + request.key, + ); + const nodeId = request.nodeId ?? nullableText(snapshot, "current_node"); + if (nodeId !== null) + this.one( + "SELECT node_id FROM nodes WHERE snapshot_id = ? AND node_id = ?", + snapshotId, + nodeId, + ); + const chain = `WITH RECURSIVE chain(node_id, parent_id, depth) AS ( + SELECT node_id, parent_id, 0 FROM nodes WHERE snapshot_id = ? AND node_id = ? + UNION ALL SELECT n.node_id, n.parent_id, c.depth + 1 FROM nodes n JOIN chain c + ON n.node_id = c.parent_id WHERE n.snapshot_id = ? AND c.depth < 10000 + )`; + const pathValues: SqlValue[] = [snapshotId, nodeId, snapshotId, snapshotId]; + const visible = request.showHidden + ? "n.role IS NOT NULL" + : "n.role IS NOT NULL AND n.hidden = 0"; + const fromChain = `FROM chain c JOIN nodes n ON n.node_id = c.node_id AND n.snapshot_id = ? WHERE ${visible}`; + const totalMessages = number( + this.one(`${chain} SELECT count(*) AS count ${fromChain}`, ...pathValues), + "count", + ); + const start = + request.offset === undefined + ? Math.max(0, Math.floor((totalMessages - 1) / LIBRARY_PAGE_SIZE) * LIBRARY_PAGE_SIZE) + : offset(request.offset); + if (start > totalMessages) + throw new ConversationLibraryError( + "invalid", + "The requested message page is outside this branch.", + ); + const messages = this.db + .prepare(`${chain} SELECT n.* ${fromChain} ORDER BY c.depth DESC LIMIT ? OFFSET ?`) + .all(...pathValues, LIBRARY_PAGE_SIZE, start) + .map(node); + const snapshotOffset = offset(request.snapshotOffset); + const snapshots = this.db + .prepare(`SELECT * FROM snapshots WHERE conversation_key = ? + ORDER BY introduced_revision DESC, id ASC LIMIT 50 OFFSET ?`) + .all(request.key, snapshotOffset) + .map((row) => ({ + id: text(row, "id"), + sourceUpdatedAt: nullableNumber(row, "source_updated_at"), + importedAt: number(row, "imported_at"), + messageCount: number(row, "message_count"), + })); + const branchOffset = offset(request.branchOffset); + const leafWhere = `FROM nodes n WHERE n.snapshot_id = ? AND NOT EXISTS + (SELECT 1 FROM nodes child WHERE child.snapshot_id = n.snapshot_id AND child.parent_id = n.node_id)`; + const branches = this.db + .prepare(`SELECT n.node_id, substr(n.text, 1, 100) AS preview ${leafWhere} + ORDER BY n.node_id LIMIT 50 OFFSET ?`) + .all(snapshotId, branchOffset) + .map((row) => ({ id: text(row, "node_id"), preview: text(row, "preview") })); + return { + kind: "detail", + conversation, + account: this.account(conversation.accountId), + snapshotId, + nodeId, + snapshotSourceUpdatedAt: nullableNumber(snapshot, "source_updated_at"), + snapshotImportedAt: number(snapshot, "imported_at"), + showHidden: request.showHidden ?? false, + messages, + totalMessages, + offset: start, + readThrough: + snapshotId === conversation.snapshotId && + nodeId !== null && + nodeId === nullableText(snapshot, "current_node") && + !conversation.conflicts + ? conversation.revision + : 0, + previousOffset: start > 0 ? Math.max(0, start - LIBRARY_PAGE_SIZE) : null, + nextOffset: start + messages.length < totalMessages ? start + messages.length : null, + snapshots, + snapshotOffset, + snapshotCount: number( + this.one("SELECT count(*) AS count FROM snapshots WHERE conversation_key = ?", request.key), + "count", + ), + branches, + branchOffset, + branchCount: number(this.one(`SELECT count(*) AS count ${leafWhere}`, snapshotId), "count"), + warnings: JSON.parse(text(snapshot, "warnings")) as string[], + }; + } + + execute(request: LibraryRequest, canWrite: boolean): LibraryReply { + if (libraryRequestMutates(request)) return this.dispatch(request, canWrite); + this.db.exec("BEGIN"); + try { + const reply = this.dispatch(request, canWrite); + this.db.exec("COMMIT"); + return reply; + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + + private dispatch(request: LibraryRequest, canWrite: boolean): LibraryReply { + if (libraryRequestMutates(request) && !canWrite) { + throw new ConversationLibraryError( + "forbidden", + "This connection cannot change the conversation library.", + ); + } + switch (request.kind) { + case "hello": + return { + kind: "hello", + protocol: CONVERSATION_LIBRARY_PROTOCOL, + revision: this.revision(), + canWrite, + capture: "not-enabled", + }; + case "accounts": + return { + kind: "accounts", + accounts: this.db + .prepare("SELECT id FROM accounts ORDER BY label, workspace, id") + .all() + .map((row) => this.account(text(row, "id"))), + }; + case "createAccount": + return this.atomic(() => { + const label = boundedLibraryString(request.label, "Account label", 200); + const workspace = boundedLibraryString(request.workspace, "Workspace label", 200); + if (number(this.one("SELECT count(*) AS count FROM accounts"), "count") >= 200) + throw new ConversationLibraryError( + "too-large", + "The library account-binding limit has been reached.", + ); + if ( + this.db + .prepare( + "SELECT id FROM accounts WHERE label = ? COLLATE NOCASE AND workspace = ? COLLATE NOCASE", + ) + .get(label, workspace) + ) { + throw new ConversationLibraryError( + "conflict", + "That account/workspace label already exists. Select its existing binding.", + ); + } + const id = NodeCrypto.randomUUID(); + this.db.prepare("INSERT INTO accounts VALUES (?, ?, ?)").run(id, label, workspace); + this.advance(); + return { kind: "account", account: { id, label, workspace } }; + }); + case "preview": + return { + kind: "preview", + conversations: normalizeConversationExport(request.conversations).map((s) => ({ + id: s.conversationId, + title: s.title, + messageCount: s.nodes.filter((n) => n.role !== null && !n.hidden).length, + warningCount: s.warnings.length, + })), + }; + case "import": + return this.importSnapshots( + request.accountId, + normalizeConversationExport(request.conversations), + ); + case "list": + return this.list(request); + case "detail": + return this.detail(request); + case "update": + return this.atomic(() => { + const current = this.conversation(request.key); + if ( + request.readThrough !== undefined && + (!Number.isSafeInteger(request.readThrough) || + request.readThrough < 0 || + request.readThrough > current.revision) + ) { + throw new ConversationLibraryError( + "invalid", + "The read marker cannot acknowledge an unobserved future revision.", + ); + } + this.db + .prepare(`UPDATE conversations SET pinned = ?, archived = ?, attention = ?, + read_revision = max(read_revision, ?) WHERE key = ?`) + .run( + Number(request.pinned ?? current.pinned), + Number(request.archived ?? current.archived), + Number(request.attention ?? current.attention), + request.readThrough ?? 0, + request.key, + ); + return { kind: "updated", revision: this.advance() }; + }); + case "selectSnapshot": + return this.atomic(() => { + const current = this.conversation(request.key); + if (current.revision !== request.expectedRevision) + throw new ConversationLibraryError( + "conflict", + "The conversation changed. Reload before choosing its default snapshot.", + ); + const selected = this.one( + "SELECT title FROM snapshots WHERE id = ? AND conversation_key = ?", + request.snapshotId, + request.key, + ); + const revision = this.advance(); + this.db + .prepare( + "UPDATE conversations SET selected_snapshot = ?, title = ?, conflicts = 0, change_revision = ? WHERE key = ?", + ) + .run(request.snapshotId, text(selected, "title"), revision, request.key); + this.indexSnapshot(request.key, request.snapshotId, text(selected, "title")); + return { kind: "updated", revision }; + }); + case "remove": + return this.atomic(() => { + if (this.conversation(request.key).revision !== request.expectedRevision) + throw new ConversationLibraryError( + "conflict", + "The conversation changed. Reload before removing the local copy.", + ); + this.db.prepare("DELETE FROM library_search WHERE key = ?").run(request.key); + this.db.prepare("DELETE FROM conversations WHERE key = ?").run(request.key); + return { kind: "removed", revision: this.advance() }; + }); + } + } +} diff --git a/apps/server/src/conversations/http.test.ts b/apps/server/src/conversations/http.test.ts new file mode 100644 index 000000000..7879df305 --- /dev/null +++ b/apps/server/src/conversations/http.test.ts @@ -0,0 +1,221 @@ +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import { Etag } from "effect/unstable/http"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as Schema from "effect/Schema"; + +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentAuthInvalidError, + EnvironmentConversationLibraryHttpApi, + type EnvironmentSessionPrincipalShape, +} from "@t3tools/contracts"; +import { LIBRARY_MAX_REQUEST_BYTES } from "@t3tools/contracts/conversationLibrary"; + +import * as ServerConfig from "../config.ts"; +import { conversationLibraryHttpApiLayer } from "./http.ts"; + +const ConversationLibraryTestApi = HttpApi.make("environment").add( + EnvironmentConversationLibraryHttpApi, +); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const testAuthenticationLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + if (authorization === undefined) { + return yield* new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "missing_credential", + traceId: "conversation-library-test", + }); + } + + const scopes = + authorization === "Bearer test-operate" + ? new Set([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]) + : new Set([AuthOrchestrationReadScope]); + const principal: EnvironmentSessionPrincipalShape = { + sessionId: + "conversation-library-test-session" as EnvironmentSessionPrincipalShape["sessionId"], + subject: "conversation-library-test-client", + method: "bearer-access-token", + scopes, + }; + return yield* httpEffect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, principal), + ); + }), +); + +const makeRouteLayer = () => + HttpApiBuilder.layer(ConversationLibraryTestApi).pipe( + Layer.provide(conversationLibraryHttpApiLayer), + Layer.provide(testAuthenticationLayer), + Layer.provide(Etag.layerWeak), + Layer.provide(NodeHttpPlatform.layer), + Layer.provide(NodeServices.layer), + ); + +const send = ( + handler: Effect.Effect, + body: string, + token?: "read" | "operate", +) => { + const headers = new Headers({ "content-type": "application/json" }); + if (token !== undefined) headers.set("authorization", `Bearer test-${token}`); + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/api/conversation-library", { + method: "POST", + headers, + body, + }), + ); + return handler.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.map(HttpServerResponse.toWeb), + ); +}; + +it.layer(NodeServices.layer)("conversation library HTTP", (it) => { + it.effect("authenticates before storage and keeps read-only opens in memory", () => + Effect.scoped( + Effect.gen(function* () { + const handler = yield* HttpRouter.toHttpEffect(makeRouteLayer()); + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const libraryDir = path.join(config.stateDir, "conversation-library"); + + const unauthorized = yield* send(handler, encodeJson({ kind: "hello" })); + expect(unauthorized.status).toBe(401); + expect(yield* fs.exists(libraryDir)).toBe(false); + + const read = yield* send(handler, encodeJson({ kind: "hello" }), "read"); + expect(read.status).toBe(200); + expect(yield* Effect.promise(() => read.json())).toMatchObject({ + kind: "hello", + canWrite: false, + }); + expect(yield* fs.exists(libraryDir)).toBe(false); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-conversation-library-http-" }), + ), + ), + ), + ); + + it.effect("requires operate scope for writes and derives hello.canWrite from the principal", () => + Effect.scoped( + Effect.gen(function* () { + const handler = yield* HttpRouter.toHttpEffect(makeRouteLayer()); + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const libraryDir = path.join(config.stateDir, "conversation-library"); + + const denied = yield* send( + handler, + encodeJson({ kind: "createAccount", label: "Personal", workspace: "local" }), + "read", + ); + expect(denied.status).toBe(403); + expect(yield* fs.exists(libraryDir)).toBe(false); + + const created = yield* send( + handler, + encodeJson({ kind: "createAccount", label: "Personal", workspace: "local" }), + "operate", + ); + expect(created.status).toBe(200); + const createdBody = yield* Effect.promise(() => created.json()); + expect(createdBody).toMatchObject({ + kind: "account", + account: { label: "Personal", workspace: "local" }, + }); + + const hello = yield* send(handler, encodeJson({ kind: "hello" }), "operate"); + expect(yield* Effect.promise(() => hello.json())).toMatchObject({ + kind: "hello", + canWrite: true, + }); + + const accounts = yield* send(handler, encodeJson({ kind: "accounts" }), "read"); + expect(yield* Effect.promise(() => accounts.json())).toMatchObject({ + kind: "accounts", + accounts: [{ label: "Personal", workspace: "local" }], + }); + expect(yield* fs.exists(path.join(libraryDir, "library.sqlite"))).toBe(true); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-conversation-library-http-" }), + ), + ), + ), + ); + + it.effect( + "returns safe errors for malformed and over-limit streamed bodies before opening storage", + () => + Effect.scoped( + Effect.gen(function* () { + const handler = yield* HttpRouter.toHttpEffect(makeRouteLayer()); + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const libraryDir = path.join(config.stateDir, "conversation-library"); + + const malformed = yield* send(handler, "{", "read"); + expect(malformed.status).toBe(400); + expect(yield* Effect.promise(() => malformed.json())).toMatchObject({ + kind: "error", + code: "invalid", + }); + expect(yield* fs.exists(libraryDir)).toBe(false); + + const oversizedPayload = encodeJson({ + kind: "preview", + conversations: [ + { + title: "Oversized", + mapping: { + node: { + message: { + author: { role: "user" }, + content: { parts: ["x".repeat(LIBRARY_MAX_REQUEST_BYTES)] }, + }, + }, + }, + }, + ], + }); + const oversized = yield* send(handler, oversizedPayload, "read"); + expect(oversizedPayload.length).toBeGreaterThan(LIBRARY_MAX_REQUEST_BYTES); + expect(oversized.status).toBe(413); + expect(yield* Effect.promise(() => oversized.json())).toMatchObject({ + kind: "error", + code: "too-large", + }); + expect(yield* fs.exists(libraryDir)).toBe(false); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-conversation-library-http-" }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/conversations/http.ts b/apps/server/src/conversations/http.ts new file mode 100644 index 000000000..4ed07ba3a --- /dev/null +++ b/apps/server/src/conversations/http.ts @@ -0,0 +1,129 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + EnvironmentHttpApi, +} from "@t3tools/contracts"; +import type { EnvironmentConversationLibraryError } from "@t3tools/contracts"; +import { + LIBRARY_MAX_REQUEST_BYTES, + LibraryRequestSchema, + type LibraryErrorCode, + type LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; +import { + libraryRequestMutates, +} from "@t3tools/shared/conversationLibrary"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { requireEnvironmentScope } from "../auth/http.ts"; +import * as ConversationLibrary from "./Service.ts"; + +const LIBRARY_ERROR_MESSAGE: Record = { + invalid: "The conversation library request is invalid.", + "too-large": "The conversation library request exceeds its size limit.", + "not-found": "The requested conversation library record was not found.", + conflict: "The conversation library changed. Reload before retrying.", + unsupported: "The conversation library format is not supported.", + storage: "The conversation library could not be accessed.", + forbidden: "The conversation library operation is not allowed.", +}; +const decodeLibraryRequestJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(LibraryRequestSchema), +); + +function conversationLibraryHttpError( + code: LibraryErrorCode, + traceId: string, +): EnvironmentConversationLibraryError { + return { + kind: "error", + code, + message: LIBRARY_ERROR_MESSAGE[code], + traceId, + } as EnvironmentConversationLibraryError; +} + +function readRequestBody(request: HttpServerRequest.HttpServerRequest, traceId: string) { + const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/json") { + return Effect.fail(conversationLibraryHttpError("invalid", traceId)); + } + + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + let tooLarge = false; + let failed = false; + return request.stream.pipe( + Stream.takeWhile((chunk) => { + receivedBytes += chunk.byteLength; + if (receivedBytes > LIBRARY_MAX_REQUEST_BYTES) { + tooLarge = true; + return false; + } + chunks.push(chunk); + return true; + }), + Stream.runDrain, + Effect.catch(() => { + failed = true; + return Effect.void; + }), + Effect.flatMap(() => { + if (tooLarge) return Effect.fail(conversationLibraryHttpError("too-large", traceId)); + if (failed) return Effect.fail(conversationLibraryHttpError("invalid", traceId)); + const bytes = new Uint8Array(receivedBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return Effect.succeed(bytes); + }), + ); +} + +function decodeLibraryRequest(bytes: Uint8Array, traceId: string) { + return Effect.try({ + try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), + catch: () => conversationLibraryHttpError("invalid", traceId), + }).pipe( + Effect.flatMap((json) => + decodeLibraryRequestJson(json).pipe( + Effect.mapError(() => conversationLibraryHttpError("invalid", traceId)), + ), + ), + ); +} + +export const conversationLibraryHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "conversationLibrary", + (handlers) => + handlers.handleRaw( + "conversationLibrary", + Effect.fn("environment.conversationLibrary")(function* ({ request }) { + const principal = yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const traceId = yield* Effect.currentParentSpan.pipe( + Effect.map((span) => span.traceId), + Effect.orElseSucceed(() => "unavailable"), + ); + const bytes = yield* readRequestBody(request, traceId); + const libraryRequest: LibraryRequest = yield* decodeLibraryRequest(bytes, traceId); + const mutates = libraryRequestMutates(libraryRequest); + if (mutates) yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + + const library = yield* ConversationLibrary.ConversationLibrary; + return yield* library.execute( + libraryRequest, + principal.scopes.has(AuthOrchestrationOperateScope), + ).pipe( + Effect.mapError((cause) => conversationLibraryHttpError(cause.code, traceId)), + ); + }), + ), +).pipe(Layer.provide(ConversationLibrary.layer)); diff --git a/apps/server/src/conversations/open.cases.ts b/apps/server/src/conversations/open.cases.ts new file mode 100644 index 000000000..5b741351e --- /dev/null +++ b/apps/server/src/conversations/open.cases.ts @@ -0,0 +1,93 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as NodeAssert from "node:assert/strict"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - These cases need real Node temporary paths, symlinks, and mode bits to exercise filesystem safety. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The synchronous filesystem cases need native path semantics outside an Effect runtime. +import * as NodePath from "node:path"; +import { openConversationLibrary } from "./open.ts"; + +async function owned(run: (path: string) => Promise): Promise { + const path = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-library-open-")); + try { + await run(path); + } finally { + NodeFS.rmSync(path, { recursive: true, force: true }); + } +} + +export const libraryOpenCases = [ + { + name: "a missing read-only library creates no files", + run: () => + owned(async (root) => { + const store = await openConversationLibrary(root, false, 123); + try { + NodeAssert.deepEqual(store.execute({ kind: "accounts" }, false), { + kind: "accounts", + accounts: [], + }); + } finally { + store.close(); + } + NodeAssert.equal(NodeFS.existsSync(NodePath.join(root, "conversation-library")), false); + }), + }, + { + name: "creates a private independent library and reopens it read-only", + run: () => + owned(async (root) => { + NodeFS.writeFileSync(NodePath.join(root, "state.sqlite"), "not the library"); + let store = await openConversationLibrary(root, true, 123); + try { + store.execute({ kind: "createAccount", label: "Example", workspace: "Personal" }, true); + } finally { + store.close(); + } + store = await openConversationLibrary(root, false, 456); + try { + const reply = store.execute({ kind: "accounts" }, false); + NodeAssert.equal(reply.kind, "accounts"); + if (reply.kind === "accounts") NodeAssert.equal(reply.accounts.length, 1); + } finally { + store.close(); + } + if (HostProcessPlatform.defaultValue() !== "win32") { + NodeAssert.equal( + NodeFS.statSync(NodePath.join(root, "conversation-library")).mode & 0o077, + 0, + ); + NodeAssert.equal( + NodeFS.statSync(NodePath.join(root, "conversation-library", "library.sqlite")).mode & + 0o077, + 0, + ); + } + NodeAssert.equal(NodeFS.statSync(NodePath.join(root, "state.sqlite")).size, 15); + }), + }, + { + name: "rejects a symlinked library directory", + run: () => + owned(async (root) => { + const target = NodePath.join(root, "target"); + NodeFS.mkdirSync(target, { mode: 0o700 }); + NodeFS.symlinkSync(target, NodePath.join(root, "conversation-library"), "dir"); + await NodeAssert.rejects(openConversationLibrary(root, true, 123), /private/); + NodeAssert.equal(NodeFS.existsSync(NodePath.join(target, "library.sqlite")), false); + }), + }, + { + name: "rejects a symlinked database without changing its target", + run: () => + owned(async (root) => { + const directory = NodePath.join(root, "conversation-library"); + NodeFS.mkdirSync(directory, { mode: 0o700 }); + const target = NodePath.join(root, "unrelated"); + NodeFS.writeFileSync(target, "protected", { mode: 0o600 }); + NodeFS.symlinkSync(target, NodePath.join(directory, "library.sqlite")); + await NodeAssert.rejects(openConversationLibrary(root, true, 123), /private/); + NodeAssert.equal(NodeFS.statSync(target).size, 9); + }), + }, +]; diff --git a/apps/server/src/conversations/open.test.ts b/apps/server/src/conversations/open.test.ts new file mode 100644 index 000000000..050e6bbfc --- /dev/null +++ b/apps/server/src/conversations/open.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vite-plus/test"; +import { libraryOpenCases } from "./open.cases.ts"; + +describe("conversation library file ownership", () => { + for (const test of libraryOpenCases) it(test.name, test.run); +}); diff --git a/apps/server/src/conversations/open.ts b/apps/server/src/conversations/open.ts new file mode 100644 index 000000000..6b23bbe41 --- /dev/null +++ b/apps/server/src/conversations/open.ts @@ -0,0 +1,124 @@ +import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The private-file check needs lstat, owner metadata, and O_NOFOLLOW/O_EXCL creation at the Node filesystem boundary. +import * as NodeFS from "node:fs"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The synchronous Node adapter needs native path semantics outside an Effect runtime. +import * as NodePath from "node:path"; +import { ConversationLibraryError } from "@t3tools/shared/conversationLibrary"; +import { ConversationLibraryStore, type LibraryDatabase } from "./Store.ts"; + +function absent(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +function inspect(path: string, directory: boolean): boolean { + try { + const info = NodeFS.lstatSync(path); + const platform = HostProcessPlatform.defaultValue(); + const ownerMismatch = () => { + const userId = HostProcessUserId.defaultValue(); + return userId !== undefined && info.uid !== userId; + }; + if ( + info.isSymbolicLink() || + (directory ? !info.isDirectory() : !info.isFile()) || + (platform !== "win32" && ((info.mode & 0o077) !== 0 || ownerMismatch())) + ) { + throw new ConversationLibraryError( + "storage", + "The library path is not a private, owner-controlled regular file or directory.", + ); + } + return true; + } catch (error) { + if (absent(error)) return false; + throw error; + } +} + +async function database(path: string, readOnly: boolean): Promise { + if (process.versions.bun) { + // Bun's builtin has different option names and a null, rather than undefined, miss. + const builtin = "bun:sqlite"; + const { + Database, + }: { + Database: new ( + path: string, + options: { readonly: boolean; strict: boolean }, + ) => { + exec(sql: string): unknown; + query(sql: string): { + get(...values: (string | number | null)[]): Record | null; + all(...values: (string | number | null)[]): Record[]; + run(...values: (string | number | null)[]): unknown; + }; + close(throwOnError: boolean): void; + }; + } = await import(builtin); + const db = new Database(path, { readonly: readOnly, strict: true }); + return { + exec: (sql) => db.exec(sql), + close: () => db.close(true), + prepare: (sql) => { + const statement = db.query(sql); + return { + get: (...values) => statement.get(...values) ?? undefined, + all: (...values) => statement.all(...values), + run: (...values) => statement.run(...values), + }; + }, + }; + } + const { DatabaseSync } = await import("node:sqlite"); + return new DatabaseSync(path, { + readOnly, + enableForeignKeyConstraints: true, + enableDoubleQuotedStringLiterals: false, + }); +} + +/** The fixed path is independent of projects and never opens the coding state database. */ +export async function openConversationLibrary( + stateDir: string, + write: boolean, + now: number, +): Promise { + const directory = NodePath.join(stateDir, "conversation-library"); + const path = NodePath.join(directory, "library.sqlite"); + let exists = inspect(directory, true); + if (!exists && write) { + try { + NodeFS.mkdirSync(directory, { mode: 0o700 }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error; + } + exists = inspect(directory, true); + } + let fileExists = exists && inspect(path, false); + if (write && !fileExists) { + try { + NodeFS.closeSync( + NodeFS.openSync( + path, + NodeFS.constants.O_CREAT | + NodeFS.constants.O_EXCL | + NodeFS.constants.O_WRONLY | + NodeFS.constants.O_NOFOLLOW, + 0o600, + ), + ); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error; + } + fileExists = inspect(path, false); + } + if (exists) for (const suffix of ["-journal", "-wal", "-shm"]) inspect(path + suffix, false); + const db = await database(fileExists ? path : ":memory:", fileExists && !write); + try { + // A missing read-only library is an empty in-memory view, not a disk mutation. + return new ConversationLibraryStore(db, { initialize: write || !fileExists, clock: () => now }); + } catch (error) { + db.close(); + throw error; + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9e9d5c5e3..e617cd163 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -169,6 +169,7 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration-v2/http.ts"; +import { conversationLibraryHttpApiLayer } from "./conversations/http.ts"; import { projectHttpApiLayer } from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; @@ -638,6 +639,7 @@ const makeRoutesLayer = Layer.mergeAll( Layer.provide(authHttpApiLayer), Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), + Layer.provide(conversationLibraryHttpApiLayer), Layer.provide(pullRequestHttpApiLayer), Layer.provide(projectHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), diff --git a/apps/web/src/components/conversations/ConversationLibraryPage.logic.test.ts b/apps/web/src/components/conversations/ConversationLibraryPage.logic.test.ts new file mode 100644 index 000000000..20e1420b2 --- /dev/null +++ b/apps/web/src/components/conversations/ConversationLibraryPage.logic.test.ts @@ -0,0 +1,106 @@ +import type { LibraryDetail } from "@t3tools/contracts/conversationLibrary"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + acknowledgeVisibleConversationDetail, + conversationLibraryDate, + conversationLibraryImportTargetAccount, + conversationLibrarySnapshotRequest, + createConversationLibraryAccountRequest, +} from "./ConversationLibraryPage.logic"; + +describe("conversation library page logic", () => { + it("keeps a preview bound to its original account after selection changes", () => { + const first = { id: "first", label: "First", workspace: "Home" }; + const second = { id: "second", label: "Second", workspace: "Work" }; + expect(conversationLibraryImportTargetAccount([first, second], second, "first")).toEqual(first); + expect(conversationLibraryImportTargetAccount([second], second, "first")).toBeNull(); + expect(conversationLibraryImportTargetAccount([first, second], second, null)).toEqual(second); + }); + + it("acknowledges a detail only while its panel is visible", () => { + const detail = {} as LibraryDetail; + const acknowledge = vi.fn(() => true); + const panel = (count: number): Pick => ({ + getClientRects: () => ({ length: count }) as DOMRectList, + }); + expect(acknowledgeVisibleConversationDetail(panel(0), detail, acknowledge)).toBe(false); + expect(acknowledgeVisibleConversationDetail(null, detail, acknowledge)).toBe(false); + expect(acknowledge).not.toHaveBeenCalled(); + expect(acknowledgeVisibleConversationDetail(panel(1), detail, acknowledge)).toBe(true); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith(detail); + }); + + it("requires a connected writable target and bounded account labels", () => { + expect(createConversationLibraryAccountRequest("Personal", "Home", false, true)).toBeNull(); + expect(createConversationLibraryAccountRequest("Personal", "Home", true, false)).toBeNull(); + expect(createConversationLibraryAccountRequest(" ", "Home", true, true)).toBeNull(); + expect(createConversationLibraryAccountRequest("Personal", " ", true, true)).toBeNull(); + expect(createConversationLibraryAccountRequest("x".repeat(201), "Home", true, true)).toBeNull(); + expect(createConversationLibraryAccountRequest(" Personal ", " Home ", true, true)).toEqual({ + kind: "createAccount", + label: "Personal", + workspace: "Home", + }); + }); + + it("interprets library timestamps as milliseconds", () => { + expect(conversationLibraryDate(1_704_067_200_000)?.toISOString()).toBe( + "2024-01-01T00:00:00.000Z", + ); + expect(conversationLibraryDate(null)).toBeNull(); + }); + + it("browses a snapshot with a detail read request", () => { + const detail = { + kind: "detail" as const, + conversation: { + key: "account:conversation", + accountId: "account", + conversationId: "conversation", + title: "A conversation", + snapshotId: "current", + sourceUpdatedAt: 1_704_067_200_000, + importedAt: 1_704_067_200_000, + revision: 4, + unread: false, + pinned: false, + archived: false, + attention: false, + conflicts: false, + messageCount: 1, + warningCount: 0, + }, + account: { id: "account", label: "Home", workspace: "Personal" }, + snapshotId: "current", + nodeId: null, + snapshotSourceUpdatedAt: 1_704_067_200_000, + snapshotImportedAt: 1_704_067_200_000, + showHidden: true, + messages: [], + totalMessages: 1, + readThrough: 0, + offset: 50, + previousOffset: 0, + nextOffset: null, + snapshots: [], + snapshotOffset: 10, + snapshotCount: 11, + branches: [], + branchOffset: 0, + branchCount: 1, + warnings: [], + }; + + expect(conversationLibrarySnapshotRequest(detail, "older")).toEqual({ + kind: "detail", + key: "account:conversation", + snapshotId: "older", + offset: 0, + snapshotOffset: 10, + branchOffset: 0, + showHidden: true, + }); + }); +}); diff --git a/apps/web/src/components/conversations/ConversationLibraryPage.logic.ts b/apps/web/src/components/conversations/ConversationLibraryPage.logic.ts new file mode 100644 index 000000000..afe11ab8b --- /dev/null +++ b/apps/web/src/components/conversations/ConversationLibraryPage.logic.ts @@ -0,0 +1,56 @@ +import type { + LibraryAccount, + LibraryDetail, + LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; + +export function conversationLibraryImportTargetAccount( + accounts: readonly LibraryAccount[] | null, + selectedAccount: LibraryAccount | null, + pendingAccountId: string | null, +): LibraryAccount | null { + return pendingAccountId === null + ? selectedAccount + : (accounts?.find((account) => account.id === pendingAccountId) ?? null); +} + +export function acknowledgeVisibleConversationDetail( + panel: Pick | null, + detail: LibraryDetail, + acknowledge: (detail: LibraryDetail) => boolean, +): boolean { + return panel !== null && panel.getClientRects().length > 0 && acknowledge(detail); +} + +export function createConversationLibraryAccountRequest( + label: string, + workspace: string, + canWrite: boolean, + connected: boolean, +): Extract | null { + const cleanLabel = label.trim(); + const cleanWorkspace = workspace.trim(); + if (!canWrite || !connected || cleanLabel.length === 0 || cleanWorkspace.length === 0) + return null; + if (cleanLabel.length > 200 || cleanWorkspace.length > 200) return null; + return { kind: "createAccount", label: cleanLabel, workspace: cleanWorkspace }; +} + +export function conversationLibraryDate(value: number | null): Date | null { + return value === null ? null : new Date(value); +} + +export function conversationLibrarySnapshotRequest( + detail: LibraryDetail, + snapshotId: string, +): Extract { + return { + kind: "detail", + key: detail.conversation.key, + snapshotId, + offset: 0, + snapshotOffset: detail.snapshotOffset, + branchOffset: 0, + showHidden: detail.showHidden, + }; +} diff --git a/apps/web/src/components/conversations/ConversationLibraryPage.test.tsx b/apps/web/src/components/conversations/ConversationLibraryPage.test.tsx new file mode 100644 index 000000000..fc80858bb --- /dev/null +++ b/apps/web/src/components/conversations/ConversationLibraryPage.test.tsx @@ -0,0 +1,26 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ConversationLibraryMessage } from "./ConversationLibraryPage"; + +describe("ConversationLibraryMessage", () => { + it("renders imported content as escaped plain text", () => { + const markup = renderToStaticMarkup( + ", + createdAt: null, + hidden: false, + unsupportedParts: 0, + }} + />, + ); + + expect(markup).toContain("<img src=x onerror=alert(1)>"); + expect(markup).not.toContain(" = [ + { id: "all", label: "All" }, + { id: "unread", label: "Unread" }, + { id: "pinned", label: "Pinned" }, + { id: "archived", label: "Archived" }, + { id: "attention", label: "Needs attention" }, +]; + +function bindingKey(binding: LibraryReaderBinding | null): string | null { + return binding === null ? null : `${binding.environmentId}:${binding.generation}`; +} + +function formatDate(value: number | null): string { + return conversationLibraryDate(value)?.toLocaleString() ?? "Date unknown"; +} + +function BindingIdentity({ + environmentName, + account, +}: { + readonly environmentName: string; + readonly account: LibraryAccount | null; +}) { + return ( +

+ Environment: {environmentName} + {account ? ( + <> + {" "} + · Account: {account.label} + + ) : null} +

+ ); +} + +export function ConversationLibraryPage() { + const { environments, isReady: environmentsReady } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const [requestedEnvironmentId, setRequestedEnvironmentId] = useState(primaryEnvironmentId); + const [importOpen, setImportOpen] = useState(false); + const [createAccountOpen, setCreateAccountOpen] = useState(false); + const [createAccountBinding, setCreateAccountBinding] = useState(null); + const [createAccountLabel, setCreateAccountLabel] = useState(""); + const [createAccountWorkspace, setCreateAccountWorkspace] = useState(""); + const [createAccountPending, setCreateAccountPending] = useState(false); + const [createAccountError, setCreateAccountError] = useState(null); + const [removeOpen, setRemoveOpen] = useState(false); + const [smallScreenDetailRequested, setSmallScreenDetailRequested] = useState(false); + const helloStartedFor = useRef(null); + const preferredAccountId = useRef(null); + const detailPanelRef = useRef(null); + const reader = useMemo(() => new ConversationLibraryReader(), []); + const importer = useMemo(() => new ConversationLibraryImport(), []); + const state = useSyncExternalStore(reader.subscribe, reader.getSnapshot, reader.getSnapshot); + const importState = useSyncExternalStore( + importer.subscribe, + importer.getSnapshot, + importer.getSnapshot, + ); + const runRequest = useAtomCommand(requestConversationLibrary, { + reportFailure: false, + reportDefect: false, + }); + + const selectedEnvironmentId = + requestedEnvironmentId !== null && + environments.some((item) => item.environmentId === requestedEnvironmentId) + ? requestedEnvironmentId + : (primaryEnvironmentId ?? environments[0]?.environmentId ?? null); + + const selectedEnvironment = + environments.find((item) => item.environmentId === selectedEnvironmentId) ?? null; + const connection = useEnvironmentQuery( + selectedEnvironmentId === null ? null : environmentCatalog.stateAtom(selectedEnvironmentId), + ); + const prepared = usePreparedConnection(selectedEnvironmentId); + const binding = useMemo( + (): LibraryReaderBinding | null => + selectedEnvironmentId === null + ? null + : { environmentId: selectedEnvironmentId, generation: connection.data?.generation ?? 0 }, + [connection.data?.generation, selectedEnvironmentId], + ); + const currentBindingKey = bindingKey(binding); + const readerBindingKey = bindingKey(state.binding); + const connected = connection.data?.phase === "connected" && Option.isSome(prepared); + const smallScreenDetail = state.selection !== null && smallScreenDetailRequested; + const stateIsBound = currentBindingKey !== null && readerBindingKey === currentBindingKey; + const libraryHandshakeComplete = stateIsBound && state.accounts.status !== "idle"; + const accountDialogOpen = + createAccountOpen && createAccountBinding === currentBindingKey && connected && state.canWrite; + const environmentName = selectedEnvironment?.label ?? "No environment selected"; + const detail = state.detail.value; + const selectedAccount = + detail?.account ?? + state.accounts.value?.find((account) => account.id === state.filter.accountId) ?? + null; + + useLayoutEffect(() => { + reader.bind(binding); + importer.bind(binding, false); + helloStartedFor.current = null; + preferredAccountId.current = null; + setImportOpen(false); + setCreateAccountOpen(false); + setCreateAccountPending(false); + setRemoveOpen(false); + setSmallScreenDetailRequested(false); + }, [binding, importer, reader]); + + useLayoutEffect(() => { + if (readerBindingKey === currentBindingKey) importer.bind(state.binding, state.canWrite); + }, [currentBindingKey, importer, readerBindingKey, state.binding, state.canWrite]); + + const executeReaderTicket = useCallback( + async function executeReaderTicket(ticket: LibraryReaderTicket) { + if (!reader.isPending(ticket)) return; + const result = await runRequest({ + environmentId: EnvironmentId.make(ticket.binding.environmentId), + input: ticket.request, + }); + if (!reader.isPending(ticket)) return; + if (result._tag === "Failure") { + reader.reject(ticket, conversationLibraryErrorMessage(Cause.squash(result.cause))); + return; + } + const reply = result.value; + if (!conversationLibraryReplyMatchesRequest(ticket.request, reply)) { + reader.reject(ticket, "The library returned a reply that did not match the request."); + return; + } + + switch (ticket.request.kind) { + case "hello": { + if (reply.kind !== "hello" || !reader.acceptHello(ticket, reply)) return; + const accountsTicket = reader.requestAccounts(); + if (accountsTicket) void executeReaderTicket(accountsTicket); + return; + } + case "accounts": { + if (reply.kind !== "accounts" || !reader.acceptAccounts(ticket, reply.accounts)) return; + const filter = reader.getSnapshot().filter; + const preferred = preferredAccountId.current; + const accountId = + preferred !== null && reply.accounts.some((account) => account.id === preferred) + ? preferred + : filter.accountId !== null && + reply.accounts.some((account) => account.id === filter.accountId) + ? filter.accountId + : (reply.accounts[0]?.id ?? null); + if (preferredAccountId.current === accountId) preferredAccountId.current = null; + reader.setFilter({ ...filter, accountId }); + return; + } + case "list": + if (reply.kind === "list" && reader.acceptList(ticket, reply)) { + const current = reader.getSnapshot(); + if (current.detail.status === "stale" && current.selection !== null) { + const detailTicket = reader.requestDetail(current.selection); + if (detailTicket) void executeReaderTicket(detailTicket); + } + } + return; + case "detail": + if (reply.kind === "detail" && reader.acceptDetail(ticket, reply)) { + const current = reader.getSnapshot(); + if (current.list.status === "stale") { + const listTicket = reader.requestList(); + if (listTicket) void executeReaderTicket(listTicket); + } + } + return; + case "update": + case "selectSnapshot": + case "remove": { + if (reply.kind !== "updated" && reply.kind !== "removed") return; + if ( + !reader.acceptMutation( + ticket as LibraryReaderTicket< + Extract + >, + reply as Extract, + ) + ) + return; + const listTicket = reader.requestList(); + if (listTicket) void executeReaderTicket(listTicket); + const selection = + ticket.request.kind === "selectSnapshot" + ? { + kind: "detail" as const, + key: ticket.request.key, + snapshotId: ticket.request.snapshotId, + offset: 0, + } + : reader.getSnapshot().selection; + if (selection !== null) { + const detailTicket = reader.requestDetail(selection); + if (detailTicket) void executeReaderTicket(detailTicket); + } + return; + } + case "preview": + case "import": + case "createAccount": + return; + } + }, + [reader, runRequest], + ); + + const executeImportTicket = useCallback( + async (ticket: ConversationImportTicket) => { + if (!importer.isPending(ticket)) return; + const result = await runRequest({ + environmentId: EnvironmentId.make(ticket.binding.environmentId), + input: ticket.request, + }); + if (!importer.isPending(ticket)) return; + if (result._tag === "Failure") { + importer.reject(ticket, conversationLibraryErrorMessage(Cause.squash(result.cause))); + return; + } + if (!conversationLibraryReplyMatchesRequest(ticket.request, result.value)) { + importer.reject(ticket, "The library returned a reply that did not match the request."); + return; + } + if (ticket.request.kind === "preview" && result.value.kind === "preview") { + importer.acceptPreview(ticket, result.value); + } else if (ticket.request.kind === "import" && result.value.kind === "imported") { + if (importer.acceptImport(ticket, result.value)) { + const listTicket = reader.requestList(); + if (listTicket) void executeReaderTicket(listTicket); + } + } + }, + [executeReaderTicket, importer, reader, runRequest], + ); + + useEffect(() => { + if (!connected) { + helloStartedFor.current = null; + return; + } + if (currentBindingKey === null || readerBindingKey !== currentBindingKey) return; + if (helloStartedFor.current === currentBindingKey) return; + helloStartedFor.current = currentBindingKey; + const ticket = reader.requestHello(); + if (ticket) void executeReaderTicket(ticket); + }, [connected, currentBindingKey, executeReaderTicket, reader, readerBindingKey]); + + useEffect(() => { + if ( + !connected || + state.accounts.status !== "ready" || + currentBindingKey === null || + readerBindingKey !== currentBindingKey + ) + return; + const timeout = window.setTimeout( + () => { + const ticket = reader.requestList(); + if (ticket) void executeReaderTicket(ticket); + }, + state.filter.query.length === 0 ? 0 : 180, + ); + return () => window.clearTimeout(timeout); + }, [ + connected, + currentBindingKey, + executeReaderTicket, + reader, + state.accounts.status, + readerBindingKey, + state.filter.accountId, + state.filter.query, + state.filter.view, + ]); + + useEffect(() => { + const detail = state.detail.value; + if (state.detail.status !== "ready" || detail === null || state.displayed) return; + const frame = window.requestAnimationFrame(() => { + if ( + !acknowledgeVisibleConversationDetail(detailPanelRef.current, detail, (visibleDetail) => + reader.acknowledgeDisplayed(visibleDetail), + ) + ) + return; + const ticket = reader.requestMarkRead(); + if (ticket) void executeReaderTicket(ticket); + }); + return () => window.cancelAnimationFrame(frame); + }, [ + executeReaderTicket, + reader, + smallScreenDetailRequested, + state.detail.status, + state.detail.value, + state.displayed, + ]); + + const reload = useCallback(() => { + const ticket = reader.requestList(); + if (ticket) void executeReaderTicket(ticket); + }, [executeReaderTicket, reader]); + + const chooseRow = useCallback( + (row: LibrarySummary) => { + const ticket = reader.requestDetail({ + kind: "detail", + key: row.key, + snapshotId: row.snapshotId, + }); + setSmallScreenDetailRequested(true); + if (ticket) void executeReaderTicket(ticket); + }, + [executeReaderTicket, reader], + ); + + const openPage = useCallback( + (detail: LibraryDetail, changes: Parameters[1]) => { + const ticket = reader.requestDetail(libraryDetailPage(detail, changes)); + if (ticket) void executeReaderTicket(ticket); + }, + [executeReaderTicket, reader], + ); + + const onImportFile = useCallback( + async (file: File | undefined) => { + const accountId = state.filter.accountId; + if (file === undefined || accountId === null) return; + const fileTicket = importer.beginFileRead(accountId); + if (fileTicket === null) return; + try { + const conversations = await readConversationExportFile(file); + const ticket = importer.requestPreviewForFile(fileTicket, conversations); + if (ticket) await executeImportTicket(ticket); + } catch (error) { + importer.rejectFileRead( + fileTicket, + error instanceof Error ? error.message : "The export could not be read.", + ); + } + }, + [executeImportTicket, importer, state.filter.accountId], + ); + + const startImport = useCallback(() => { + const ticket = importer.requestImport(); + if (ticket) void executeImportTicket(ticket); + }, [executeImportTicket, importer]); + + const createAccountRequest = useMemo( + () => + createConversationLibraryAccountRequest( + createAccountLabel, + createAccountWorkspace, + state.canWrite, + connected, + ), + [connected, createAccountLabel, createAccountWorkspace, state.canWrite], + ); + const createAccount = useCallback(async () => { + if (!createAccountRequest || binding === null) { + setCreateAccountError( + !state.canWrite + ? "This environment does not allow library changes." + : !connected + ? "Connect the environment before creating an account." + : "Enter a label and workspace, each no longer than 200 characters.", + ); + return; + } + const requestedBinding = bindingKey(binding); + setCreateAccountPending(true); + setCreateAccountError(null); + const result = await runRequest({ + environmentId: EnvironmentId.make(binding.environmentId), + input: createAccountRequest, + }); + if (bindingKey(reader.getSnapshot().binding) !== requestedBinding) return; + setCreateAccountPending(false); + if (result._tag === "Failure") { + setCreateAccountError(conversationLibraryErrorMessage(Cause.squash(result.cause))); + return; + } + if (result.value.kind !== "account") { + setCreateAccountError("The environment returned an unexpected account response."); + return; + } + preferredAccountId.current = result.value.account.id; + setCreateAccountLabel(""); + setCreateAccountWorkspace(""); + setCreateAccountOpen(false); + const ticket = reader.requestAccounts(); + if (ticket) void executeReaderTicket(ticket); + }, [ + binding, + connected, + createAccountRequest, + executeReaderTicket, + reader, + runRequest, + state.canWrite, + ]); + + const runMutation = useCallback( + (makeTicket: () => LibraryReaderTicket | null) => { + const ticket = makeTicket(); + if (ticket) void executeReaderTicket(ticket); + }, + [executeReaderTicket], + ); + + const list = state.list.value; + const accountLabel = selectedAccount + ? `${selectedAccount.label} · ${selectedAccount.workspace}` + : "No account selected"; + const importTargetAccount = conversationLibraryImportTargetAccount( + state.accounts.value, + selectedAccount, + importState.accountId, + ); + const importTargetLabel = importTargetAccount + ? `${importTargetAccount.label} · ${importTargetAccount.workspace}` + : "Account unavailable"; + + return ( + +
+ +
+

Conversation Library

+
+ + + +
+
+
+ + +
+
+

+ A local library of imported conversation snapshots. +

+ +
+
+ + {!connected + ? connection.data?.phase === "connecting" + ? "Connecting…" + : "Environment offline" + : state.canWrite + ? "Read and write access" + : libraryHandshakeComplete + ? "Read-only access" + : "Checking library access…"} + + +
+
+ + {!environmentsReady ? : null} + {environmentsReady && environments.length === 0 ? ( + + ) : null} + {selectedEnvironmentId !== null && !connected ? ( + + ) : null} + {state.error ? : null} + {libraryHandshakeComplete && state.canWrite === false && state.error === null ? ( +
+ This library is read-only. Import and library changes are disabled. +
+ ) : null} + {connected && state.accounts.status === "error" ? ( + { + const ticket = reader.requestAccounts(); + if (ticket) void executeReaderTicket(ticket); + }} + /> + ) : null} + +
+
+
+
+ + + +
+ + reader.setFilter({ + ...reader.getSnapshot().filter, + query: event.currentTarget.value, + }) + } + disabled={!connected || state.accounts.status !== "ready"} + /> +
+ {VIEWS.map((view) => ( + + ))} +
+
+ +
+ {state.list.status === "loading" && list === null ? ( + + ) : null} + {state.list.status === "error" ? ( + + ) : null} + {state.list.status === "ready" && list?.rows.length === 0 ? ( + + ) : null} + {list?.rows.map((row) => ( + chooseRow(row)} + /> + ))} + {list?.cursor ? ( + + ) : null} + {!connected || + state.accounts.status === "loading" || + state.accounts.status === "idle" ? ( + + ) : null} +
+
+ +
+ {detail ? ( + <> +
+
+ +
+

+ {detail.conversation.title || "Untitled conversation"} +

+

+ {accountLabel} · Imported {formatDate(detail.snapshotImportedAt)} +

+
+ + + + +
+
+ + +
+ {detail.snapshotId !== detail.conversation.snapshotId ? ( + + ) : null} +
+ + {detail.totalMessages} messages · Snapshot {detail.snapshotOffset + 1}– + {Math.min( + detail.snapshotOffset + detail.snapshots.length, + detail.snapshotCount, + )}{" "} + of {detail.snapshotCount} · Branch {detail.branchOffset + 1}– + {Math.min(detail.branchOffset + detail.branches.length, detail.branchCount)}{" "} + of {detail.branchCount} + +
+ + + + +
+
+
+
+ {detail.warnings.length > 0 ? ( +
+ {detail.warnings.join(" ")} +
+ ) : null} + +
+ {detail.messages.map((message) => ( + + ))} +
+
+ + +
+
+ + ) : state.detail.status === "loading" ? ( + + ) : state.detail.status === "error" ? ( + { + if (state.selection) { + const ticket = reader.requestDetail(state.selection); + if (ticket) void executeReaderTicket(ticket); + } + }} + /> + ) : ( + + )} +
+
+
+
+ + { + setImportOpen(open); + if (!open) importer.cancel(); + }} + > + + + Import conversations + + Choose a conversations.json export to preview it before adding it to this library. + + +
+ +

Target account: {importTargetLabel}

+ + {importState.status === "previewing" ? ( +

+ Reading and validating the selected file… +

+ ) : null} + {importState.error ? ( +
+ {importState.error} +
+ ) : null} + {importState.preview ? ( +
+

+ Preview · {importState.preview.length} conversations +

+ {importState.preview.map((item) => ( +
+ + {item.title || "Untitled conversation"} + + + {item.messageCount} messages + {item.warningCount ? ` · ${item.warningCount} warnings` : ""} + +
+ ))} +

+ Confirming import sends this validated export to {environmentName} /{" "} + {importTargetLabel}. +

+
+ ) : null} + {importState.status === "complete" && importState.result ? ( +

+ Imported {importState.result.inserted}; skipped {importState.result.duplicates}{" "} + duplicates and {importState.result.older} older snapshots;{" "} + {importState.result.conflicts} conflicts. +

+ ) : null} +
+ + + + +
+
+ + { + setCreateAccountOpen(open); + if (open) setCreateAccountBinding(currentBindingKey); + else setCreateAccountError(null); + }} + > + + + Create a library account + + Create an account/workspace label for imported conversations in {environmentName}. + + +
{ + event.preventDefault(); + void createAccount(); + }} + > + + + + {createAccountError ? ( +

+ {createAccountError} +

+ ) : null} +
+ + +
+ +
+
+ + + + + Remove this local copy? + + This removes the selected conversation snapshot from {environmentName} /{" "} + {accountLabel}. It does not change the source export. + + + + + + + + +
+ ); +} + +function ConversationRow({ + row, + selected, + onClick, +}: { + readonly row: LibrarySummary; + readonly selected: boolean; + readonly onClick: () => void; +}) { + return ( + + ); +} + +export function ConversationLibraryMessage({ + message, +}: { + readonly message: LibraryDetail["messages"][number]; +}) { + return ( +
+
+ + {message.role ?? "Message"} + {message.hidden ? " · hidden" : ""} + + +
+

+ {message.text || "[No text content]"} +

+ {message.unsupportedParts > 0 ? ( +

+ {message.unsupportedParts} non-text content item + {message.unsupportedParts === 1 ? "" : "s"} omitted. +

+ ) : null} +
+ ); +} + +function StatusCard({ title, detail }: { readonly title: string; readonly detail?: string }) { + return ( +
+

{title}

+ {detail ?

{detail}

: null} +
+ ); +} + +function ErrorCard({ + message, + onRetry, +}: { + readonly message: string; + readonly onRetry: () => void; +}) { + return ( +
+ + {message} + +
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index a0db338db..60600852c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { ArrowLeftIcon, BookOpenIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; @@ -198,6 +198,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { search: readPullRequestListPreferences(), }); }, [closeMobileSidebar, navigate]); + const handleConversationsClick = useCallback(() => { + closeMobileSidebar(); + void navigate({ to: "/conversations" }); + }, [closeMobileSidebar, navigate]); const handleSettingsClick = useCallback(() => { closeMobileSidebar(); void navigate({ to: "/settings" }); @@ -238,6 +242,11 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { onClick={handlePullRequestsClick} /> ) : null} + } + label="Conversation Library" + onClick={handleConversationsClick} + /> } label="Usage" diff --git a/apps/web/src/components/sidebar/mainAppLocation.test.ts b/apps/web/src/components/sidebar/mainAppLocation.test.ts new file mode 100644 index 000000000..89b27a9d1 --- /dev/null +++ b/apps/web/src/components/sidebar/mainAppLocation.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isSidebarUtilityPage } from "./mainAppLocation"; + +describe("sidebar utility locations", () => { + it("keeps the conversation library out of main-app return history", () => { + expect(isSidebarUtilityPage("/conversations")).toBe(true); + expect(isSidebarUtilityPage("/pull-requests")).toBe(true); + expect(isSidebarUtilityPage("/")).toBe(false); + }); +}); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index fcbf2f489..19b135989 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,7 +1,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect } from "react"; -// Settings, Usage, and Pull Requests replace the sidebar utility row with a +// Settings, Usage, Pull Requests, and Conversation Library replace the sidebar utility row with a // Back button. Everything else is the main app. Legacy `/projects/` links // redirect into settings, so they count too and are never remembered. export function isSidebarUtilityPage(pathname: string) { @@ -10,7 +10,8 @@ export function isSidebarUtilityPage(pathname: string) { pathname.startsWith("/settings/") || pathname.startsWith("/projects/") || pathname === "/usage" || - pathname === "/pull-requests" + pathname === "/pull-requests" || + pathname === "/conversations" ); } diff --git a/apps/web/src/routes/_chat.conversations.tsx b/apps/web/src/routes/_chat.conversations.tsx new file mode 100644 index 000000000..2bf00ed93 --- /dev/null +++ b/apps/web/src/routes/_chat.conversations.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConversationLibraryPage } from "../components/conversations/ConversationLibraryPage"; + +export const Route = createFileRoute("/_chat/conversations")({ + component: ConversationLibraryPage, +}); diff --git a/apps/web/src/state/conversations.ts b/apps/web/src/state/conversations.ts new file mode 100644 index 000000000..d698b2946 --- /dev/null +++ b/apps/web/src/state/conversations.ts @@ -0,0 +1,90 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { LibraryReply, LibraryRequest } from "@t3tools/contracts/conversationLibrary"; +import { fetchEnvironmentConversationLibraryRequest } from "@t3tools/client-runtime/conversations"; +import { EnvironmentSupervisor } from "@t3tools/client-runtime/connection"; +import * as ManagedRelay from "@t3tools/client-runtime/relay"; +import { createEnvironmentCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +class ConversationLibraryConnectionNotReadyError extends Data.TaggedError( + "ConversationLibraryConnectionNotReadyError", +)<{ readonly message: string }> {} + +/** + * Run the conversation-library HTTP adapter inside the selected environment's + * supervisor. This keeps requests on the current prepared credential and + * supports cookie, bearer, and relay DPoP authentication through the shared + * runtime HTTP client. + */ +export const requestConversationLibrary = createEnvironmentCommand(connectionAtomRuntime, { + label: "web-conversation-library:request", + execute: (request: LibraryRequest, _registry, environmentId) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new ConversationLibraryConnectionNotReadyError({ + message: "The environment HTTP connection is not ready.", + }); + } + if (prepared.value.environmentId !== EnvironmentId.make(environmentId)) { + return yield* new ConversationLibraryConnectionNotReadyError({ + message: "The environment connection changed. Refresh before trying again.", + }); + } + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelay.ManagedRelayDpopSigner); + return yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared.value, + request, + signer, + }); + }), +}); + +export function conversationLibraryReplyMatchesRequest( + request: LibraryRequest, + reply: LibraryReply, +): boolean { + switch (request.kind) { + case "hello": + return reply.kind === "hello"; + case "accounts": + return reply.kind === "accounts"; + case "createAccount": + return reply.kind === "account"; + case "preview": + return reply.kind === "preview"; + case "import": + return reply.kind === "imported"; + case "list": + return reply.kind === "list"; + case "detail": + return reply.kind === "detail"; + case "update": + return reply.kind === "updated"; + case "selectSnapshot": + return reply.kind === "updated"; + case "remove": + return reply.kind === "removed"; + } +} + +export function conversationLibraryErrorMessage(cause: unknown): string { + const error = cause instanceof Error ? cause : null; + const code = error && "code" in error ? String(error.code) : null; + if (code === "unsupported") { + return "This environment server does not support the Conversation Library."; + } + if (code === "forbidden") { + return "This account does not have permission to use the Conversation Library."; + } + if (code === "conflict") { + return "The library changed while this request was running. Refresh to reconcile the current state."; + } + return error?.message.trim() || "The Conversation Library request failed."; +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index b812495cb..8d8088a57 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./conversations": { + "types": "./src/conversations/index.ts", + "default": "./src/conversations/index.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/packages/client-runtime/src/conversations/http.test.ts b/packages/client-runtime/src/conversations/http.test.ts new file mode 100644 index 000000000..74c9fd111 --- /dev/null +++ b/packages/client-runtime/src/conversations/http.test.ts @@ -0,0 +1,192 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { fetchEnvironmentConversationLibraryRequest } from "./http.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Primary", + httpBaseUrl: "https://environment.example.test/base", + wsBaseUrl: "wss://environment.example.test", +}); + +const prepared = ( + httpAuthorization: PreparedConnection["httpAuthorization"] = null, +): PreparedConnection => ({ + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization, + target: TARGET, +}); + +const hello = { + kind: "hello", + protocol: "t3.conversation-library.v1", + revision: 1, + canWrite: true, + capture: "not-enabled", +}; + +const fetchWithJson = ( + body: unknown, + status = 200, + onRequest?: (request: RequestInfo | URL, init: RequestInit) => void, +): typeof fetch => + ((request, init) => { + onRequest?.(request, init ?? {}); + return Promise.resolve(Response.json(body, { status })); + }) satisfies typeof fetch; + +describe("conversation library HTTP transport", () => { + it.effect("uses cookie credentials for the primary environment and posts the typed request", () => + Effect.gen(function* () { + const calls: Array = []; + const result = yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared(), + request: { kind: "hello" }, + signer: Option.none(), + }).pipe( + Effect.provide( + remoteHttpClientLayer( + fetchWithJson(hello, 200, (request, init) => calls.push([request, init])), + ), + ), + ); + + expect(result).toEqual(hello); + expect(calls).toHaveLength(1); + const [request, init] = calls[0]!; + expect(String(request)).toBe("https://environment.example.test/api/conversation-library"); + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("include"); + const body = + typeof init.body === "string" + ? init.body + : init.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : ""; + expect(body).toContain('"kind":"hello"'); + }), + ); + + it.effect("sends bearer authorization without cookie credentials", () => + Effect.gen(function* () { + let requestInit: RequestInit | undefined; + yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared({ _tag: "Bearer", token: "environment-token" }), + request: { kind: "hello" }, + signer: Option.none(), + }).pipe( + Effect.provide( + remoteHttpClientLayer( + fetchWithJson(hello, 200, (_request, init) => { + requestInit = init; + }), + ), + ), + ); + + expect(new Headers(requestInit?.headers).get("authorization")).toBe( + "Bearer environment-token", + ); + expect(requestInit?.credentials).toBeUndefined(); + }), + ); + + it.effect("binds DPoP proofs to the conversation library POST URL", () => + Effect.gen(function* () { + let proofInput: Parameters[0] | undefined; + let requestInit: RequestInit | undefined; + const signer: ManagedRelayDpopSigner["Service"] = { + thumbprint: Effect.succeed("thumbprint"), + createProof: (input) => { + proofInput = input; + return Effect.succeed("proof-value"); + }, + }; + const remoteAuthorization = RemoteEnvironmentAuthorization.of({ + authorizeBearer: () => Effect.die("unused"), + authorizeDpop: () => Effect.die("unused"), + authorizeDpopHttp: () => + Effect.succeed({ + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "relay-token", + expiresAtEpochMs: 10_000, + }, + }), + }); + + yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared({ _tag: "Dpop", accessToken: "relay-token", expiresAtEpochMs: 10_000 }), + request: { kind: "hello" }, + signer: Option.some(signer), + }).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, remoteAuthorization), + Effect.provide( + remoteHttpClientLayer( + fetchWithJson(hello, 200, (_request, init) => { + requestInit = init; + }), + ), + ), + ); + + expect(proofInput).toEqual({ + method: "POST", + url: "https://environment.example.test/api/conversation-library", + accessToken: "relay-token", + }); + expect(new Headers(requestInit?.headers).get("authorization")).toBe("DPoP relay-token"); + expect(new Headers(requestInit?.headers).get("dpop")).toBe("proof-value"); + expect(requestInit?.credentials).toBeUndefined(); + }), + ); + + it.effect("preserves the unsupported feature code returned by an older server", () => + Effect.gen(function* () { + const unsupported = { + kind: "error", + code: "unsupported", + message: "Conversation library is not available on this server.", + traceId: "trace-old-server", + }; + const error = yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared(), + request: { kind: "hello" }, + signer: Option.none(), + }).pipe(Effect.provide(remoteHttpClientLayer(fetchWithJson(unsupported, 501))), Effect.flip); + + expect(error).toMatchObject({ code: "unsupported", traceId: "trace-old-server" }); + }), + ); + + it.effect("returns structured library errors without erasing their domain code", () => + Effect.gen(function* () { + const conflict = { + kind: "error", + code: "conflict", + message: "The conversation library changed.", + traceId: "trace-conflict", + }; + const error = yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared(), + request: { kind: "import", accountId: "account-1", conversations: [] }, + signer: Option.none(), + }).pipe(Effect.provide(remoteHttpClientLayer(fetchWithJson(conflict, 409))), Effect.flip); + + expect(error).toMatchObject({ code: "conflict", traceId: "trace-conflict" }); + }), + ); +}); diff --git a/packages/client-runtime/src/conversations/http.ts b/packages/client-runtime/src/conversations/http.ts new file mode 100644 index 000000000..85dfe1c55 --- /dev/null +++ b/packages/client-runtime/src/conversations/http.ts @@ -0,0 +1,76 @@ +import { + EnvironmentConversationLibraryErrorSchema, + type EnvironmentConversationLibraryError, +} from "@t3tools/contracts"; +import type { LibraryRequest } from "@t3tools/contracts/conversationLibrary"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { + makeEnvironmentHttpApiUrlBuilder, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "../state/environmentHttpAuth.ts"; + +const DEFAULT_CONVERSATION_LIBRARY_TIMEOUT_MS = 30_000; +const isConversationLibraryError = Schema.is(EnvironmentConversationLibraryErrorSchema); + +export type ConversationLibraryRequestError = + | RemoteEnvironmentRequestError + | EnvironmentConversationLibraryError; + +export const fetchEnvironmentConversationLibraryRequest = Effect.fn( + "clientRuntime.conversations.fetchEnvironmentConversationLibraryRequest", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly request: LibraryRequest; + readonly signer: Option.Option; + readonly remoteAuthorization?: Option.Option; + readonly timeoutMs?: number; +}) { + const remoteAuthorization = + input.remoteAuthorization ?? (yield* Effect.serviceOption(RemoteEnvironmentAuthorization)); + return yield* executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization, + group: "conversationLibrary", + method: "POST", + url: (httpBaseUrl) => + makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).conversationLibrary.conversationLibrary(), + timeoutMs: input.timeoutMs ?? DEFAULT_CONVERSATION_LIBRARY_TIMEOUT_MS, + request: ({ client, headers }) => { + switch (input.request.kind) { + case "hello": + return client.conversationLibrary({ payload: input.request, headers }); + case "accounts": + return client.conversationLibrary({ payload: input.request, headers }); + case "createAccount": + return client.conversationLibrary({ payload: input.request, headers }); + case "preview": + return client.conversationLibrary({ payload: input.request, headers }); + case "import": + return client.conversationLibrary({ payload: input.request, headers }); + case "list": + return client.conversationLibrary({ payload: input.request, headers }); + case "detail": + return client.conversationLibrary({ payload: input.request, headers }); + case "update": + return client.conversationLibrary({ payload: input.request, headers }); + case "selectSnapshot": + return client.conversationLibrary({ payload: input.request, headers }); + case "remove": + return client.conversationLibrary({ payload: input.request, headers }); + } + }, + }).pipe( + Effect.mapError((error) => + error._tag === "RemoteEnvironmentAuthFetchError" && isConversationLibraryError(error.cause) + ? error.cause + : error, + ), + ); +}); diff --git a/packages/client-runtime/src/conversations/import.test.ts b/packages/client-runtime/src/conversations/import.test.ts new file mode 100644 index 000000000..dc80f0638 --- /dev/null +++ b/packages/client-runtime/src/conversations/import.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { LIBRARY_MAX_REQUEST_BYTES } from "@t3tools/contracts/conversationLibrary"; +import { + ConversationImportFileError, + ConversationLibraryImport, + readConversationExportFile, +} from "./import.ts"; + +const binding = { environmentId: "environment-a", generation: 1 }; + +const conversation = { + id: "conversation-1", + title: "Exported conversation", + mapping: { + node: { + message: { + author: { role: "user" }, + content: { parts: ["Hello"] }, + }, + }, + }, +} as const; + +function file(name = "conversations.json", value: unknown = [conversation], size?: number) { + const text = JSON.stringify(value); + return { + name, + size: size ?? new TextEncoder().encode(text).byteLength, + text: async () => text, + }; +} + +describe("conversation library import", () => { + it("accepts only a valid conversations.json export", async () => { + await expect(readConversationExportFile(file())).resolves.toMatchObject([ + { id: "conversation-1", title: "Exported conversation" }, + ]); + await expect(readConversationExportFile(file("backup.json"))).rejects.toMatchObject({ + name: "ConversationImportFileError", + code: "invalid", + }); + await expect( + readConversationExportFile(file("conversations.json", [{ title: "missing mapping" }])), + ).rejects.toMatchObject({ + name: "ConversationImportFileError", + code: "invalid", + }); + }); + + it("rejects an oversized file before reading it and checks the serialized request size", async () => { + let read = false; + const tooLargeFile = { + name: "conversations.json", + size: LIBRARY_MAX_REQUEST_BYTES + 1, + text: async () => { + read = true; + return "[]"; + }, + }; + await expect(readConversationExportFile(tooLargeFile)).rejects.toBeInstanceOf( + ConversationImportFileError, + ); + expect(read).toBe(false); + + const largeConversation = { + ...conversation, + title: "x".repeat(LIBRARY_MAX_REQUEST_BYTES), + }; + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + expect(importer.requestPreview("account-1", [largeConversation])).toBeNull(); + expect(importer.getSnapshot()).toMatchObject({ status: "error" }); + expect(importer.getSnapshot().error).toContain("too large"); + }); + + it("requires accepted preview and write capability before a separate import request", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, false); + const preview = importer.requestPreview("account-1", [conversation]); + expect(preview?.request.kind).toBe("preview"); + expect( + importer.acceptPreview(preview!, { + kind: "preview", + conversations: [ + { + id: "conversation-1", + title: "Exported conversation", + messageCount: 1, + warningCount: 0, + }, + ], + }), + ).toBe(true); + expect(importer.getSnapshot()).toMatchObject({ status: "ready", accountId: "account-1" }); + expect(importer.requestImport()).toBeNull(); + + importer.bind(binding, true); + const confirmed = importer.requestImport(); + expect(confirmed?.request).toMatchObject({ kind: "import", accountId: "account-1" }); + expect(importer.getSnapshot().status).toBe("importing"); + expect( + importer.acceptImport(confirmed!, { + kind: "imported", + inserted: 1, + duplicates: 0, + older: 0, + conflicts: 0, + revision: 2, + }), + ).toBe(true); + expect(importer.getSnapshot()).toMatchObject({ + status: "complete", + preview: null, + result: { inserted: 1 }, + }); + }); + + it("fences stale preview and import completions across connection generations", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + const preview = importer.requestPreview("account-1", [conversation]); + expect(preview).not.toBeNull(); + importer.bind({ ...binding, generation: 2 }, true); + expect(importer.acceptPreview(preview!, { kind: "preview", conversations: [] })).toBe(false); + expect(importer.getSnapshot()).toMatchObject({ status: "idle", binding: { generation: 2 } }); + + const currentPreview = importer.requestPreview("account-1", [conversation]); + expect(currentPreview).not.toBeNull(); + expect(importer.acceptPreview(currentPreview!, { kind: "preview", conversations: [] })).toBe( + true, + ); + const importTicket = importer.requestImport(); + expect(importTicket).not.toBeNull(); + importer.bind({ ...binding, generation: 3 }, true); + expect( + importer.acceptImport(importTicket!, { + kind: "imported", + inserted: 1, + duplicates: 0, + older: 0, + conflicts: 0, + revision: 3, + }), + ).toBe(false); + expect(importer.getSnapshot()).toMatchObject({ status: "idle", binding: { generation: 3 } }); + }); + + it("fences a file read that finishes after the selected environment changes", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + const fileRead = importer.beginFileRead("account-1"); + expect(fileRead).not.toBeNull(); + importer.bind({ ...binding, generation: 2 }, true); + expect(importer.requestPreviewForFile(fileRead!, [conversation])).toBeNull(); + expect(importer.getSnapshot()).toMatchObject({ status: "idle", binding: { generation: 2 } }); + }); + + it("cancel releases the prepared payload and invalidates the pending reply", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + const ticket = importer.requestPreview("account-1", [conversation]); + expect(ticket).not.toBeNull(); + importer.cancel(); + expect(importer.isPending(ticket!)).toBe(false); + expect(importer.getSnapshot()).toMatchObject({ + status: "idle", + accountId: null, + preview: null, + }); + }); +}); diff --git a/packages/client-runtime/src/conversations/import.ts b/packages/client-runtime/src/conversations/import.ts new file mode 100644 index 000000000..be8c6774e --- /dev/null +++ b/packages/client-runtime/src/conversations/import.ts @@ -0,0 +1,343 @@ +import type { + ExportConversation, + LibraryReply, + LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; +import { + LIBRARY_MAX_REQUEST_BYTES, + LibraryRequestSchema, +} from "@t3tools/contracts/conversationLibrary"; +import * as Schema from "effect/Schema"; + +import type { LibraryReaderBinding } from "./model.ts"; + +export type ConversationImportFileErrorCode = "invalid" | "too-large"; +const decodeLibraryRequest = Schema.decodeUnknownSync(LibraryRequestSchema); + +export class ConversationImportFileError extends Error { + override readonly name = "ConversationImportFileError"; + readonly code: ConversationImportFileErrorCode; + + constructor(code: ConversationImportFileErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export interface ConversationImportPreviewItem { + readonly id: string; + readonly title: string; + readonly messageCount: number; + readonly warningCount: number; +} + +export interface ConversationImportResult { + readonly inserted: number; + readonly duplicates: number; + readonly older: number; + readonly conflicts: number; + readonly revision: number; +} + +export interface ConversationImportState { + readonly binding: LibraryReaderBinding | null; + readonly canWrite: boolean; + readonly status: "idle" | "previewing" | "ready" | "importing" | "complete" | "error"; + readonly accountId: string | null; + readonly preview: readonly ConversationImportPreviewItem[] | null; + readonly result: ConversationImportResult | null; + readonly error: string | null; +} + +export interface ConversationImportTicket { + readonly binding: LibraryReaderBinding; + readonly request: R; +} + +export interface ConversationImportFileReadTicket { + readonly binding: LibraryReaderBinding; + readonly accountId: string; +} + +type PreviewRequest = Extract; +type ImportRequest = Extract; +type PreviewReply = Extract; +type ImportedReply = Extract; + +const idleState = ( + binding: LibraryReaderBinding | null, + canWrite = false, +): ConversationImportState => ({ + binding, + canWrite, + status: "idle", + accountId: null, + preview: null, + result: null, + error: null, +}); + +function serializedRequestBytes(request: LibraryRequest): number { + return new TextEncoder().encode(JSON.stringify(request)).byteLength; +} + +function checkedRequest(request: R): R { + if (serializedRequestBytes(request) > LIBRARY_MAX_REQUEST_BYTES) { + throw new ConversationImportFileError( + "too-large", + "The selected export is too large to send to this conversation library.", + ); + } + return request; +} + +export async function readConversationExportFile( + file: Pick, +): Promise { + if (file.name !== "conversations.json") { + throw new ConversationImportFileError("invalid", "Choose the conversations.json export file."); + } + if (file.size > LIBRARY_MAX_REQUEST_BYTES) { + throw new ConversationImportFileError( + "too-large", + "The selected export exceeds the 16 MiB limit.", + ); + } + + let value: unknown; + try { + value = JSON.parse(await file.text()); + } catch { + throw new ConversationImportFileError( + "invalid", + "The selected conversations.json file is not valid JSON.", + ); + } + + try { + const parsed = decodeLibraryRequest({ + kind: "preview", + conversations: value, + }); + if (parsed.kind !== "preview") { + throw new ConversationImportFileError( + "invalid", + "The selected export does not contain a conversations list.", + ); + } + checkedRequest(parsed); + return parsed.conversations; + } catch (error) { + if (error instanceof ConversationImportFileError) throw error; + throw new ConversationImportFileError( + "invalid", + "The selected conversations.json file does not match the supported export format.", + ); + } +} + +export class ConversationLibraryImport { + private state: ConversationImportState = idleState(null); + private readonly listeners = new Set<() => void>(); + private pending: ConversationImportTicket | null = null; + private fileRead: ConversationImportFileReadTicket | null = null; + private prepared: ImportRequest | null = null; + + readonly getSnapshot = (): ConversationImportState => this.state; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private publish(state: ConversationImportState): void { + this.state = state; + for (const listener of this.listeners) listener(); + } + + bind(binding: LibraryReaderBinding | null, canWrite: boolean): void { + const current = this.state.binding; + if ( + current === binding || + (current !== null && + binding !== null && + current.environmentId === binding.environmentId && + current.generation === binding.generation) + ) { + if (this.state.canWrite !== canWrite) this.publish({ ...this.state, canWrite }); + return; + } + this.pending = null; + this.fileRead = null; + this.prepared = null; + this.publish(idleState(binding === null ? null : { ...binding }, canWrite)); + } + + beginFileRead(accountId: string): ConversationImportFileReadTicket | null { + const binding = this.state.binding; + if (binding === null || accountId.trim().length === 0) return null; + const ticket: ConversationImportFileReadTicket = { + binding, + accountId, + }; + this.pending = null; + this.fileRead = ticket; + this.prepared = null; + this.publish({ ...idleState(binding, this.state.canWrite), status: "previewing", accountId }); + return ticket; + } + + requestPreviewForFile( + ticket: ConversationImportFileReadTicket, + conversations: readonly ExportConversation[], + ): ConversationImportTicket | null { + if (this.fileRead !== ticket || !this.sameBinding(ticket.binding, this.state.binding)) + return null; + this.fileRead = null; + return this.requestPreview(ticket.accountId, conversations); + } + + rejectFileRead(ticket: ConversationImportFileReadTicket, message: string): boolean { + if (this.fileRead !== ticket || !this.sameBinding(ticket.binding, this.state.binding)) + return false; + this.fileRead = null; + this.publish({ ...this.state, status: "error", preview: null, error: message }); + return true; + } + + requestPreview( + accountId: string, + conversations: readonly ExportConversation[], + ): ConversationImportTicket | null { + const binding = this.state.binding; + if (binding === null || accountId.trim().length === 0) return null; + this.fileRead = null; + let request: PreviewRequest; + try { + request = checkedRequest({ kind: "preview", conversations }); + } catch (error) { + this.prepared = null; + this.pending = null; + this.publish({ + ...idleState(binding, this.state.canWrite), + status: "error", + error: error instanceof Error ? error.message : "The export is too large.", + }); + return null; + } + this.fileRead = null; + const ticket: ConversationImportTicket = { binding, request }; + this.pending = ticket; + this.prepared = null; + this.publish({ ...idleState(binding, this.state.canWrite), status: "previewing", accountId }); + return ticket; + } + + isPending(ticket: ConversationImportTicket): boolean { + return this.pending === ticket && this.sameBinding(ticket.binding, this.state.binding); + } + + acceptPreview(ticket: ConversationImportTicket, reply: PreviewReply): boolean { + if (!this.isPending(ticket) || ticket.request.kind !== "preview" || reply.kind !== "preview") + return false; + const accountId = this.state.accountId; + if (accountId === null) return false; + let request: ImportRequest; + try { + request = checkedRequest({ + kind: "import", + accountId, + conversations: ticket.request.conversations, + }); + } catch (error) { + this.pending = null; + this.prepared = null; + this.publish({ + ...this.state, + status: "error", + preview: null, + error: error instanceof Error ? error.message : "The export is too large.", + }); + return false; + } + this.pending = null; + this.prepared = request; + this.publish({ ...this.state, status: "ready", preview: reply.conversations, error: null }); + return true; + } + + requestImport(): ConversationImportTicket | null { + const binding = this.state.binding; + const request = this.prepared; + if ( + binding === null || + request === null || + !this.state.canWrite || + this.state.status !== "ready" + ) + return null; + try { + checkedRequest(request); + } catch (error) { + this.prepared = null; + this.publish({ + ...this.state, + status: "error", + preview: null, + error: error instanceof Error ? error.message : "The export is too large.", + }); + return null; + } + const ticket: ConversationImportTicket = { binding, request }; + this.pending = ticket; + this.publish({ ...this.state, status: "importing", error: null }); + return ticket; + } + + acceptImport(ticket: ConversationImportTicket, reply: ImportedReply): boolean { + if (!this.isPending(ticket) || ticket.request.kind !== "import" || reply.kind !== "imported") + return false; + this.pending = null; + this.prepared = null; + this.publish({ + ...this.state, + status: "complete", + preview: null, + result: reply, + error: null, + }); + return true; + } + + reject(ticket: ConversationImportTicket, message: string): boolean { + if (!this.isPending(ticket)) return false; + this.pending = null; + this.prepared = null; + this.publish({ ...this.state, status: "error", preview: null, error: message }); + return true; + } + + cancel(): void { + this.pending = null; + this.fileRead = null; + this.prepared = null; + this.publish( + idleState( + this.state.binding === null ? null : { ...this.state.binding }, + this.state.canWrite, + ), + ); + } + + private sameBinding(a: LibraryReaderBinding | null, b: LibraryReaderBinding | null): boolean { + return ( + a === b || + (a !== null && + b !== null && + a.environmentId === b.environmentId && + a.generation === b.generation) + ); + } +} diff --git a/packages/client-runtime/src/conversations/index.ts b/packages/client-runtime/src/conversations/index.ts new file mode 100644 index 000000000..567587e83 --- /dev/null +++ b/packages/client-runtime/src/conversations/index.ts @@ -0,0 +1,3 @@ +export * from "./model.ts"; +export * from "./http.ts"; +export * from "./import.ts"; diff --git a/packages/client-runtime/src/conversations/model.cases.ts b/packages/client-runtime/src/conversations/model.cases.ts new file mode 100644 index 000000000..0d330b4db --- /dev/null +++ b/packages/client-runtime/src/conversations/model.cases.ts @@ -0,0 +1,686 @@ +import { assert } from "vite-plus/test"; +import type { + LibraryDetail, + LibraryReply, + LibrarySummary, +} from "@t3tools/contracts/conversationLibrary"; +import { ConversationLibraryReader, libraryDetailPage, libraryReaderRowKey } from "./model.ts"; + +const binding = { environmentId: "environment-a", generation: 1 }; +const hello = (revision = 1, canWrite = true): Extract => ({ + kind: "hello", + protocol: "t3.conversation-library.v1", + revision, + canWrite, + capture: "not-enabled", +}); +const row = (patch: Partial = {}): LibrarySummary => ({ + key: "account-a:conversation-1", + accountId: "account-a", + conversationId: "conversation-1", + title: "Supplied export", + snapshotId: "snapshot-1", + sourceUpdatedAt: 1000, + importedAt: 2000, + revision: 1, + unread: true, + pinned: false, + archived: false, + attention: false, + conflicts: false, + messageCount: 1, + warningCount: 1, + ...patch, +}); +const detail = (patch: Partial = {}): LibraryDetail => ({ + kind: "detail", + conversation: row(), + account: { id: "account-a", label: "Personal", workspace: "Personal" }, + snapshotId: "snapshot-1", + snapshotSourceUpdatedAt: 1000, + snapshotImportedAt: 2000, + showHidden: false, + nodeId: "node-1", + messages: [ + { + id: "node-1", + parentId: "missing-parent", + messageId: "message-1", + role: "assistant", + text: "", + createdAt: 1000, + hidden: false, + unsupportedParts: 1, + }, + ], + totalMessages: 1, + readThrough: 1, + offset: 0, + previousOffset: null, + nextOffset: null, + snapshots: [{ id: "snapshot-1", sourceUpdatedAt: 1000, importedAt: 2000, messageCount: 1 }], + snapshotOffset: 0, + snapshotCount: 1, + branches: [{ id: "node-1", preview: "Supplied text" }], + branchOffset: 0, + branchCount: 1, + warnings: ["This branch has a history gap; non-text media is unavailable."], + ...patch, +}); +const page = ( + rows: readonly LibrarySummary[] = [row()], + revision = 1, + cursor: string | null = null, +): Extract => ({ kind: "list", rows, revision, cursor }); + +function present(reader: ConversationLibraryReader, value = detail()): LibraryDetail { + const ticket = reader.requestDetail({ + kind: "detail", + key: value.conversation.key, + snapshotId: value.snapshotId, + }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, value), true); + return value; +} + +function ready(canWrite = true): ConversationLibraryReader { + const reader = new ConversationLibraryReader(); + reader.bind(binding); + const ticket = reader.requestHello(); + assert.ok(ticket); + assert.equal(reader.acceptHello(ticket, hello(1, canWrite)), true); + return reader; +} + +export const conversationLibraryReaderCases: readonly { + readonly name: string; + readonly run: () => void; +}[] = [ + { + name: "disconnected readers cannot issue requests or grant themselves writes", + run: () => { + const reader = new ConversationLibraryReader(); + assert.equal(reader.requestHello(), null); + assert.equal(reader.requestList(), null); + assert.equal(reader.requestDetail({ kind: "detail", key: "x" }), null); + assert.equal(reader.requestFlags({ pinned: true }), null); + }, + }, + { + name: "connection generation changes clear records and capabilities", + run: () => { + const reader = ready(); + present(reader); + reader.bind({ ...binding, generation: 2 }); + assert.equal(reader.getSnapshot().detail.value, null); + assert.equal(reader.getSnapshot().canWrite, false); + }, + }, + { + name: "environment changes reject old hello capabilities", + run: () => { + const reader = ready(); + const old = reader.requestHello(); + assert.ok(old); + reader.bind({ environmentId: "environment-b", generation: 1 }); + assert.equal(reader.acceptHello(old, hello(10)), false); + assert.equal(reader.getSnapshot().revision, 0); + assert.equal(reader.getSnapshot().canWrite, false); + }, + }, + { + name: "same environment and generation is a stable subscription snapshot", + run: () => { + const reader = ready(); + const before = reader.getSnapshot(); + reader.bind({ ...binding }); + assert.equal(reader.getSnapshot(), before); + }, + }, + { + name: "unsubscribed listeners receive no further notifications", + run: () => { + const reader = ready(); + let calls = 0; + const unsubscribe = reader.subscribe(() => { + calls++; + }); + present(reader); + assert.equal(calls, 2); + unsubscribe(); + reader.bind(null); + assert.equal(calls, 2); + }, + }, + { + name: "account changes clear selection and fence in-flight detail", + run: () => { + const reader = ready(); + const old = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(old); + reader.setFilter({ accountId: "account-b", query: "", view: "all" }); + assert.equal(reader.acceptDetail(old, detail()), false); + assert.equal(reader.getSnapshot().detail.value, null); + }, + }, + { + name: "latest selection wins when detail replies arrive out of order", + run: () => { + const reader = ready(); + const old = reader.requestDetail({ kind: "detail", key: "old" }); + assert.ok(old); + const current = present(reader); + assert.equal(reader.acceptDetail(old, detail({ conversation: row({ key: "old" }) })), false); + assert.equal(reader.getSnapshot().detail.value, current); + }, + }, + { + name: "a different account reply is rejected even when the key matches", + run: () => { + const reader = ready(); + reader.setFilter({ accountId: "account-b", query: "", view: "all" }); + const ticket = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, detail()), false); + assert.equal(reader.getSnapshot().detail.status, "error"); + }, + }, + { + name: "summary and account identities must agree", + run: () => { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(ticket); + assert.equal( + reader.acceptDetail( + ticket, + detail({ account: { id: "wrong", label: "Personal", workspace: "Personal" } }), + ), + false, + ); + }, + }, + { + name: "explicit snapshots never silently fall back to the default", + run: () => { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key, snapshotId: "older" }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, detail()), false); + }, + }, + { + name: "explicit branches and page windows are verified", + run: () => { + for (const request of [ + { nodeId: "other" }, + { offset: 50 }, + { snapshotOffset: 50 }, + { branchOffset: 50 }, + { showHidden: true }, + ]) { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key, ...request }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, detail()), false); + } + }, + }, + { + name: "search and view changes fence prior list replies", + run: () => { + const reader = ready(); + const old = reader.requestList(); + assert.ok(old); + reader.setFilter({ accountId: null, query: " supplied ", view: "attention" }); + assert.equal(reader.acceptList(old, page()), false); + assert.deepEqual(reader.requestList()?.request, { + kind: "list", + query: "supplied", + view: "attention", + }); + }, + }, + { + name: "account filters use the binding ID rather than display labels", + run: () => { + const reader = ready(); + reader.setFilter({ accountId: "workspace-specific-account-id", query: "", view: "pinned" }); + assert.equal(reader.requestList()?.request.accountId, "workspace-specific-account-id"); + }, + }, + { + name: "same-revision catalog pages append without losing order", + run: () => { + const reader = ready(); + const first = reader.requestList(); + assert.ok(first); + assert.equal(reader.acceptList(first, page([row()], 1, "cursor")), true); + const next = reader.requestList(true); + assert.ok(next); + assert.equal(next.request.cursor, "cursor"); + assert.equal(reader.acceptList(next, page([row({ key: "second" })])), true); + assert.deepEqual( + reader.getSnapshot().list.value?.rows.map((item) => item.key), + [row().key, "second"], + ); + }, + }, + { + name: "catalog revision changes never splice incompatible pages", + run: () => { + const reader = ready(); + const first = reader.requestList(); + assert.ok(first); + reader.acceptList(first, page([row()], 1, "cursor")); + const next = reader.requestList(true); + assert.ok(next); + assert.equal(reader.acceptList(next, page([row({ key: "second" })], 2)), false); + assert.equal(reader.getSnapshot().list.status, "error"); + assert.equal(reader.requestList(true), null); + assert.equal(reader.getSnapshot().list.value?.rows.length, 1); + }, + }, + { + name: "stale first pages cannot overwrite a newer observed revision", + run: () => { + const reader = ready(); + reader.observeRevision(3); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.acceptList(ticket, page([row()], 2)), false); + assert.equal(reader.getSnapshot().revision, 3); + }, + }, + { + name: "duplicate and cross-account catalog rows fail closed", + run: () => { + for (const rows of [[row(), row()], [row({ accountId: "account-b" })]]) { + const reader = ready(); + reader.setFilter({ accountId: "account-a", query: "", view: "all" }); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.acceptList(ticket, page(rows)), false); + } + }, + }, + { + name: "observing a newer revision invalidates pending reads and displayed receipts", + run: () => { + const reader = ready(); + const value = present(reader); + reader.acknowledgeDisplayed(value); + const pending = reader.requestList(); + assert.ok(pending); + reader.observeRevision(2); + assert.equal(reader.acceptList(pending, page()), false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.equal(reader.getSnapshot().displayed, false); + assert.equal(reader.requestMarkRead(), null); + }, + }, + { + name: "network receipt alone never marks a conversation read", + run: () => { + const reader = ready(); + present(reader); + assert.equal(reader.requestMarkRead(), null); + assert.equal(reader.getSnapshot().detail.value?.conversation.unread, true); + }, + }, + { + name: "only the exact displayed current page can produce a read acknowledgement", + run: () => { + const reader = ready(); + const value = present(reader); + assert.equal(reader.acknowledgeDisplayed({ ...value }), false); + assert.equal(reader.acknowledgeDisplayed(value), true); + assert.deepEqual(reader.requestMarkRead()?.request, { + kind: "update", + key: row().key, + readThrough: 1, + }); + }, + }, + { + name: "historical snapshots, alternate branches, gaps without selection, and early pages stay unread", + run: () => { + const variants: readonly Partial[] = [ + { snapshotId: "older", readThrough: 0 }, + { nodeId: "alternate", readThrough: 0 }, + { nodeId: null, messages: [], totalMessages: 0, readThrough: 0 }, + { nextOffset: 50, totalMessages: 100 }, + { nextOffset: null, totalMessages: 100 }, + { conversation: row({ conflicts: true }) }, + { conversation: row({ unread: false }) }, + { readThrough: 2 }, + ]; + for (const variant of variants) { + const reader = ready(); + const value = present(reader, detail(variant)); + reader.acknowledgeDisplayed(value); + assert.equal(reader.requestMarkRead(), null); + } + }, + }, + { + name: "read-only clients cannot alter flags, select defaults, remove, or acknowledge reads", + run: () => { + const reader = ready(false); + const value = present(reader); + reader.acknowledgeDisplayed(value); + assert.equal(reader.requestFlags({ pinned: true }), null); + assert.equal(reader.requestSelectSnapshot("older"), null); + assert.equal(reader.requestRemoveLocalCopy(), null); + assert.equal(reader.requestMarkRead(), null); + }, + }, + { + name: "flag changes carry only explicit flag fields and never smuggle a read marker", + run: () => { + const reader = ready(); + present(reader); + const flags = { pinned: true, readThrough: 999, key: "different" }; + assert.deepEqual(reader.requestFlags(flags)?.request, { + kind: "update", + key: row().key, + pinned: true, + }); + assert.equal(reader.getSnapshot().detail.value?.conversation.pinned, false); + }, + }, + { + name: "writes are single-flight and never applied optimistically", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestFlags({ attention: true }); + assert.ok(ticket); + assert.equal(reader.requestFlags({ archived: true }), null); + assert.equal(reader.getSnapshot().detail.value?.conversation.attention, false); + assert.equal(reader.acceptMutation(ticket, { kind: "updated", revision: 2 }), true); + assert.equal(reader.getSnapshot().mutationPending, false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.equal(reader.requestFlags({ archived: true }), null); + }, + }, + { + name: "snapshot selection and local removal carry the observed content revision", + run: () => { + const select = ready(); + present(select); + assert.deepEqual(select.requestSelectSnapshot("older")?.request, { + kind: "selectSnapshot", + key: row().key, + snapshotId: "older", + expectedRevision: 1, + }); + const remove = ready(); + present(remove); + assert.deepEqual(remove.requestRemoveLocalCopy()?.request, { + kind: "remove", + key: row().key, + expectedRevision: 1, + }); + }, + }, + { + name: "local removal clears only the record whose removal was acknowledged", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestRemoveLocalCopy(); + assert.ok(ticket); + const other = present(reader, detail({ conversation: row({ key: "other" }) })); + assert.equal(reader.acceptMutation(ticket, { kind: "removed", revision: 2 }), true); + assert.equal(reader.getSnapshot().detail.value, other); + assert.equal(reader.getSnapshot().detail.status, "stale"); + }, + }, + { + name: "removing the selected local copy clears its displayed state", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestRemoveLocalCopy(); + assert.ok(ticket); + reader.acceptMutation(ticket, { kind: "removed", revision: 2 }); + assert.equal(reader.getSnapshot().detail.value, null); + }, + }, + { + name: "a mutation from the old connection cannot alter the new connection", + run: () => { + const reader = ready(); + present(reader); + const old = reader.requestFlags({ pinned: true }); + assert.ok(old); + reader.bind({ ...binding, generation: 2 }); + assert.equal(reader.acceptMutation(old, { kind: "updated", revision: 5 }), false); + assert.equal(reader.getSnapshot().revision, 0); + }, + }, + { + name: "wrong mutation reply kinds invalidate the view instead of inventing success", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestRemoveLocalCopy(); + assert.ok(ticket); + assert.equal(reader.acceptMutation(ticket, { kind: "updated", revision: 2 }), false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.match(reader.getSnapshot().error ?? "", /reconcile/); + }, + }, + { + name: "unknown write outcomes require refresh and are never retried by the model", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestFlags({ pinned: true }); + assert.ok(ticket); + assert.equal(reader.reject(ticket, "Connection lost; outcome unknown."), true); + assert.equal(reader.getSnapshot().mutationPending, false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.equal(reader.requestFlags({ pinned: true }), null); + }, + }, + { + name: "a failed obsolete request cannot replace the current error or page", + run: () => { + const reader = ready(); + const old = reader.requestDetail({ kind: "detail", key: "old" }); + assert.ok(old); + const value = present(reader); + assert.equal(reader.reject(old, "obsolete failure"), false); + assert.equal(reader.getSnapshot().detail.value, value); + }, + }, + { + name: "capability refreshes can revoke write affordances without deleting reading data", + run: () => { + const reader = ready(); + const value = present(reader); + const ticket = reader.requestHello(); + assert.ok(ticket); + reader.acceptHello(ticket, hello(1, false)); + assert.equal(reader.getSnapshot().detail.value, value); + assert.equal(reader.requestFlags({ pinned: true }), null); + }, + }, + { + name: "history gaps, unsupported media markers, roles, and branch order remain untouched", + run: () => { + const reader = ready(); + const value = present(reader); + assert.equal(reader.getSnapshot().detail.value, value); + assert.equal(reader.getSnapshot().detail.value?.messages[0]?.unsupportedParts, 1); + assert.equal(reader.getSnapshot().detail.value?.warnings, value.warnings); + assert.equal( + reader.getSnapshot().detail.value?.messages[0]?.text, + "", + ); + }, + }, + { + name: "message, snapshot, and branch paging remain anchored to the visible snapshot", + run: () => { + const value = detail(); + assert.deepEqual(libraryDetailPage(value, { offset: 50, branchOffset: 50 }), { + kind: "detail", + key: row().key, + snapshotId: "snapshot-1", + nodeId: "node-1", + offset: 50, + snapshotOffset: 0, + branchOffset: 50, + showHidden: false, + }); + const wider = { offset: 50, key: "wrong", snapshotId: "wrong" }; + assert.equal(libraryDetailPage(value, wider).key, row().key); + assert.equal(libraryDetailPage(value, wider).snapshotId, "snapshot-1"); + }, + }, + { + name: "render keys distinguish account/workspace bindings and connection generations", + run: () => { + const a = libraryReaderRowKey(binding, row()); + assert.notEqual( + a, + libraryReaderRowKey(binding, row({ accountId: "same-label-other-workspace" })), + ); + assert.notEqual(a, libraryReaderRowKey({ ...binding, generation: 2 }, row())); + assert.notEqual( + a, + libraryReaderRowKey({ ...binding, environmentId: "environment-b" }, row()), + ); + }, + }, + { + name: "capability replies older than observed data cannot restore write affordances", + run: () => { + const reader = ready(); + reader.observeRevision(2); + const ticket = reader.requestHello(); + assert.ok(ticket); + assert.equal(reader.acceptHello(ticket, hello(1, true)), false); + assert.equal(reader.getSnapshot().canWrite, false); + }, + }, + { + name: "late successful mutation acknowledgements never regress a newer observed revision", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestFlags({ pinned: true }); + assert.ok(ticket); + reader.observeRevision(5); + assert.equal(reader.acceptMutation(ticket, { kind: "updated", revision: 2 }), true); + assert.equal(reader.getSnapshot().revision, 5); + assert.equal(reader.getSnapshot().mutationPending, false); + assert.equal(reader.getSnapshot().error, null); + }, + }, + { + name: "clearing selection fences its pending detail without changing the account filter", + run: () => { + const reader = ready(); + reader.setFilter({ accountId: "account-a", query: "", view: "all" }); + const ticket = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(ticket); + reader.clearSelection(); + assert.equal(reader.acceptDetail(ticket, detail()), false); + assert.equal(reader.getSnapshot().selection, null); + assert.equal(reader.getSnapshot().filter.accountId, "account-a"); + }, + }, + { + name: "a failed detail keeps an explicit retry location instead of reusing another selection", + run: () => { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key, snapshotId: "older" }); + assert.ok(ticket); + reader.reject(ticket, "Temporary failure"); + assert.deepEqual(reader.getSnapshot().selection, ticket.request); + assert.equal(reader.getSnapshot().detail.value, null); + }, + }, + { + name: "successful detail receipts anchor future refreshes to the displayed snapshot and branch", + run: () => { + const reader = ready(); + const value = present(reader); + assert.deepEqual(reader.getSnapshot().selection, libraryDetailPage(value, {})); + }, + }, + { + name: "tickets cannot be replayed or replaced by a lookalike object", + run: () => { + const reader = ready(); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.acceptList({ ...ticket }, page()), false); + assert.equal(reader.acceptList(ticket, page()), true); + assert.equal(reader.acceptList(ticket, page([], 2)), false); + assert.equal(reader.getSnapshot().list.value?.rows.length, 1); + }, + }, + { + name: "reply acceptance is atomic when a subscriber changes the active environment", + run: () => { + for (const lane of ["hello", "list", "detail"] as const) { + const reader = ready(); + reader.subscribe(() => { + const state = reader.getSnapshot(); + if (state.binding?.environmentId === binding.environmentId && state.revision === 2) { + reader.bind({ environmentId: "environment-b", generation: 1 }); + } + }); + if (lane === "hello") { + const ticket = reader.requestHello(); + assert.ok(ticket); + reader.acceptHello(ticket, hello(2)); + } else if (lane === "list") { + const ticket = reader.requestList(); + assert.ok(ticket); + reader.acceptList(ticket, page([row()], 2)); + } else { + present(reader, detail({ conversation: row({ revision: 2 }), readThrough: 2 })); + } + assert.equal(reader.getSnapshot().binding?.environmentId, "environment-b"); + assert.equal(reader.getSnapshot().revision, 0); + assert.equal(reader.getSnapshot().canWrite, false); + assert.equal(reader.getSnapshot().list.value, null); + assert.equal(reader.getSnapshot().detail.value, null); + } + }, + }, + { + name: "a rebind during a request notification prevents dispatch of the obsolete ticket", + run: () => { + const reader = ready(); + reader.subscribe(() => { + if (reader.getSnapshot().list.status === "loading") reader.bind(null); + }); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.isPending(ticket), false); + }, + }, + { + name: "only the currently pending exact ticket is dispatchable", + run: () => { + const reader = ready(); + const old = reader.requestList(); + assert.ok(old); + assert.equal(reader.isPending(old), true); + const next = reader.requestList(); + assert.ok(next); + assert.equal(reader.isPending(old), false); + assert.equal(reader.isPending(next), true); + reader.acceptList(next, page()); + assert.equal(reader.isPending(next), false); + }, + }, +]; diff --git a/packages/client-runtime/src/conversations/model.test.ts b/packages/client-runtime/src/conversations/model.test.ts new file mode 100644 index 000000000..feb24bcb8 --- /dev/null +++ b/packages/client-runtime/src/conversations/model.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vite-plus/test"; +import { conversationLibraryReaderCases } from "./model.cases.ts"; + +describe("conversation library reader", () => { + for (const test of conversationLibraryReaderCases) it(test.name, test.run); +}); diff --git a/packages/client-runtime/src/conversations/model.ts b/packages/client-runtime/src/conversations/model.ts new file mode 100644 index 000000000..2340ef6a8 --- /dev/null +++ b/packages/client-runtime/src/conversations/model.ts @@ -0,0 +1,491 @@ +import type { + LibraryAccount, + LibraryDetail, + LibraryReply, + LibraryRequest, + LibrarySummary, + LibraryView, +} from "@t3tools/contracts/conversationLibrary"; + +export interface LibraryReaderBinding { + readonly environmentId: string; + readonly generation: number; +} + +export interface LibraryReaderFilter { + readonly accountId: string | null; + readonly query: string; + readonly view: LibraryView; +} + +type ListReply = Extract; +type HelloReply = Extract; +type DetailRequest = Extract; +type UpdateRequest = Extract; +type MutationRequest = Extract< + LibraryRequest, + { readonly kind: "update" | "selectSnapshot" | "remove" } +>; +type ReadRequest = Extract< + LibraryRequest, + { readonly kind: "hello" | "accounts" | "list" | "detail" } +>; +type Lane = ReadRequest["kind"] | "mutation"; + +export interface LibraryReaderPage
{ + readonly status: "idle" | "loading" | "ready" | "stale" | "error"; + readonly value: A | null; + readonly error: string | null; +} + +export interface LibraryReaderState { + readonly binding: LibraryReaderBinding | null; + readonly filter: LibraryReaderFilter; + readonly canWrite: boolean; + readonly revision: number; + readonly accounts: LibraryReaderPage; + readonly list: LibraryReaderPage; + readonly detail: LibraryReaderPage; + readonly selection: DetailRequest | null; + readonly displayed: boolean; + readonly mutationPending: boolean; + readonly error: string | null; +} + +export interface LibraryReaderTicket { + readonly binding: LibraryReaderBinding; + readonly request: R; +} + +const idle = (): LibraryReaderPage => ({ status: "idle", value: null, error: null }); +const initialFilter: LibraryReaderFilter = { accountId: null, query: "", view: "all" }; +const initialState = (binding: LibraryReaderBinding | null): LibraryReaderState => ({ + binding, + filter: initialFilter, + canWrite: false, + revision: 0, + accounts: idle(), + list: idle(), + detail: idle(), + selection: null, + displayed: false, + mutationPending: false, + error: null, +}); + +function stale(page: LibraryReaderPage): LibraryReaderPage { + return { status: page.value === null ? "idle" : "stale", value: page.value, error: null }; +} + +function sameBinding(a: LibraryReaderBinding | null, b: LibraryReaderBinding | null): boolean { + return ( + a === b || + (a !== null && + b !== null && + a.environmentId === b.environmentId && + a.generation === b.generation) + ); +} + +function detailMatches( + request: DetailRequest, + detail: LibraryDetail, + accountId: string | null, +): boolean { + return ( + detail.conversation.key === request.key && + detail.account.id === detail.conversation.accountId && + (accountId === null || detail.account.id === accountId) && + (request.snapshotId === undefined || request.snapshotId === detail.snapshotId) && + (request.nodeId === undefined || request.nodeId === detail.nodeId) && + (request.offset === undefined || request.offset === detail.offset) && + (request.snapshotOffset ?? 0) === detail.snapshotOffset && + (request.branchOffset ?? 0) === detail.branchOffset && + (request.showHidden ?? false) === detail.showHidden + ); +} + +/** + * Client-only presentation state. Transport adapters must decode replies first and + * rebind whenever T3's environment connection generation changes. Tickets identify + * pending work; they are not authorization and never execute a request themselves. + */ +export class ConversationLibraryReader { + private state: LibraryReaderState = initialState(null); + private readonly listeners = new Set<() => void>(); + private readonly pending = new Map(); + private appendList = false; + + readonly getSnapshot = (): LibraryReaderState => this.state; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private publish(state: LibraryReaderState): void { + this.state = state; + for (const listener of this.listeners) listener(); + } + + bind(binding: LibraryReaderBinding | null): void { + if (sameBinding(this.state.binding, binding)) return; + this.pending.clear(); + this.appendList = false; + this.publish(initialState(binding === null ? null : { ...binding })); + } + + setFilter(filter: LibraryReaderFilter): void { + const next = { ...filter, query: filter.query.trim() }; + const current = this.state.filter; + if ( + current.accountId === next.accountId && + current.query === next.query && + current.view === next.view + ) + return; + this.pending.delete("list"); + this.pending.delete("detail"); + this.appendList = false; + this.publish({ + ...this.state, + filter: next, + list: idle(), + detail: idle(), + selection: null, + displayed: false, + error: null, + }); + } + + clearSelection(): void { + this.pending.delete("detail"); + this.publish({ ...this.state, detail: idle(), selection: null, displayed: false }); + } + + private begin(lane: Lane, request: R): LibraryReaderTicket | null { + const binding = this.state.binding; + if (binding === null) return null; + const ticket = { binding, request }; + this.pending.set(lane, ticket); + return ticket; + } + + private owns(lane: Lane, ticket: LibraryReaderTicket): boolean { + return this.pending.get(lane) === ticket && sameBinding(ticket.binding, this.state.binding); + } + + /** Check immediately before dispatching through the matching T3 connection. */ + isPending(ticket: LibraryReaderTicket): boolean { + return [...this.pending.keys()].some((lane) => this.owns(lane, ticket)); + } + + requestHello(): LibraryReaderTicket> | null { + return this.begin("hello", { kind: "hello" }); + } + + acceptHello(ticket: LibraryReaderTicket, reply: HelloReply): boolean { + if (!this.owns("hello", ticket)) return false; + this.pending.delete("hello"); + if (reply.revision < this.state.revision) { + this.publish({ + ...this.state, + canWrite: false, + error: "The library capability reply is stale. Refresh the connection.", + }); + return false; + } + const state = this.advanceRevision(reply.revision); + this.publish({ ...state, canWrite: reply.canWrite, error: null }); + return true; + } + + requestAccounts(): LibraryReaderTicket> | null { + const ticket = this.begin("accounts", { kind: "accounts" }); + if (ticket) + this.publish({ + ...this.state, + accounts: { ...this.state.accounts, status: "loading", error: null }, + }); + return ticket; + } + + acceptAccounts(ticket: LibraryReaderTicket, accounts: readonly LibraryAccount[]): boolean { + if (!this.owns("accounts", ticket)) return false; + this.pending.delete("accounts"); + this.publish({ ...this.state, accounts: { status: "ready", value: accounts, error: null } }); + return true; + } + + requestList( + append = false, + ): LibraryReaderTicket> | null { + const list = this.state.list; + if (append && (list.status !== "ready" || list.value?.cursor == null)) return null; + const { accountId, query, view } = this.state.filter; + const ticket = this.begin("list", { + kind: "list", + ...(accountId === null ? {} : { accountId }), + query, + view, + ...(append && list.value?.cursor ? { cursor: list.value.cursor } : {}), + }); + if (ticket) { + this.appendList = append; + this.publish({ ...this.state, list: { ...list, status: "loading", error: null } }); + } + return ticket; + } + + acceptList(ticket: LibraryReaderTicket, reply: ListReply): boolean { + if (!this.owns("list", ticket)) return false; + const prior = this.state.list.value; + if ( + reply.revision < this.state.revision || + (this.appendList && prior?.revision !== reply.revision) + ) { + this.reject(ticket, "The library changed. Refresh before loading another page."); + return false; + } + const rows = this.appendList && prior ? [...prior.rows, ...reply.rows] : reply.rows; + if ( + new Set(rows.map((row) => row.key)).size !== rows.length || + rows.some( + (row) => + this.state.filter.accountId !== null && row.accountId !== this.state.filter.accountId, + ) + ) { + this.reject(ticket, "The library returned an inconsistent conversation page."); + return false; + } + this.pending.delete("list"); + const state = this.advanceRevision(reply.revision); + this.publish({ ...state, list: { status: "ready", value: { ...reply, rows }, error: null } }); + return true; + } + + requestDetail(request: DetailRequest): LibraryReaderTicket | null { + const ticket = this.begin("detail", { ...request }); + if (ticket) + this.publish({ + ...this.state, + detail: { status: "loading", value: null, error: null }, + selection: ticket.request, + displayed: false, + }); + return ticket; + } + + acceptDetail(ticket: LibraryReaderTicket, reply: LibraryDetail): boolean { + if (!this.owns("detail", ticket) || ticket.request.kind !== "detail") return false; + if (!detailMatches(ticket.request, reply, this.state.filter.accountId)) { + this.reject( + ticket, + "The library returned a different conversation, snapshot, branch, or page.", + ); + return false; + } + this.pending.delete("detail"); + const state = this.advanceRevision(reply.conversation.revision); + this.publish({ + ...state, + detail: { status: "ready", value: reply, error: null }, + selection: libraryDetailPage(reply, {}), + displayed: false, + }); + return true; + } + + /** Call after rendering this exact page, not on network receipt or prefetch. */ + acknowledgeDisplayed(detail: LibraryDetail): boolean { + if (this.state.detail.status !== "ready" || this.state.detail.value !== detail) return false; + if (!this.state.displayed) this.publish({ ...this.state, displayed: true }); + return true; + } + + private advanceRevision(revision: number): LibraryReaderState { + if (revision <= this.state.revision) return this.state; + this.pending.delete("list"); + this.pending.delete("detail"); + return { + ...this.state, + revision, + list: stale(this.state.list), + detail: stale(this.state.detail), + displayed: false, + }; + } + + observeRevision(revision: number): void { + const state = this.advanceRevision(revision); + if (state !== this.state) this.publish(state); + } + + private mutate(request: MutationRequest): LibraryReaderTicket | null { + const detail = this.state.detail; + if ( + !this.state.canWrite || + this.state.mutationPending || + detail.status !== "ready" || + detail.value?.conversation.key !== request.key + ) + return null; + const ticket = this.begin("mutation", request); + if (ticket) this.publish({ ...this.state, mutationPending: true, error: null }); + return ticket; + } + + requestFlags( + flags: Pick, + ): LibraryReaderTicket | null { + const conversation = this.state.detail.value?.conversation; + if ( + !conversation || + (flags.pinned === undefined && flags.archived === undefined && flags.attention === undefined) + ) + return null; + return this.mutate({ + kind: "update", + key: conversation.key, + ...(flags.pinned === undefined ? {} : { pinned: flags.pinned }), + ...(flags.archived === undefined ? {} : { archived: flags.archived }), + ...(flags.attention === undefined ? {} : { attention: flags.attention }), + }); + } + + requestMarkRead(): LibraryReaderTicket | null { + const detail = this.state.detail.value; + if ( + !detail || + !this.state.displayed || + !detail.conversation.unread || + detail.conversation.conflicts || + detail.snapshotId !== detail.conversation.snapshotId || + detail.nodeId === null || + detail.messages.length === 0 || + detail.nextOffset !== null || + detail.offset + detail.messages.length !== detail.totalMessages || + detail.readThrough <= 0 || + detail.readThrough !== detail.conversation.revision + ) + return null; + return this.mutate({ + kind: "update", + key: detail.conversation.key, + readThrough: detail.readThrough, + }); + } + + requestSelectSnapshot(snapshotId: string): LibraryReaderTicket | null { + const conversation = this.state.detail.value?.conversation; + return conversation + ? this.mutate({ + kind: "selectSnapshot", + key: conversation.key, + snapshotId, + expectedRevision: conversation.revision, + }) + : null; + } + + requestRemoveLocalCopy(): LibraryReaderTicket | null { + const conversation = this.state.detail.value?.conversation; + return conversation + ? this.mutate({ + kind: "remove", + key: conversation.key, + expectedRevision: conversation.revision, + }) + : null; + } + + acceptMutation( + ticket: LibraryReaderTicket, + reply: Extract, + ): boolean { + if (!this.owns("mutation", ticket)) return false; + const expected = ticket.request.kind === "remove" ? "removed" : "updated"; + if (reply.kind !== expected) { + this.reject( + ticket, + "The library mutation response is inconsistent. Refresh to reconcile its outcome.", + ); + return false; + } + this.pending.delete("mutation"); + this.pending.delete("list"); + this.pending.delete("detail"); + const removedSelected = + ticket.request.kind === "remove" && this.state.selection?.key === ticket.request.key; + this.publish({ + ...this.state, + revision: Math.max(this.state.revision, reply.revision), + mutationPending: false, + list: stale(this.state.list), + detail: removedSelected ? idle() : stale(this.state.detail), + selection: removedSelected ? null : this.state.selection, + displayed: false, + }); + return true; + } + + /** A failed write is not retried here: its outcome may be unknown to the client. */ + reject(ticket: LibraryReaderTicket, message: string): boolean { + const lane = [...this.pending].find(([, pending]) => pending === ticket)?.[0]; + if (lane === undefined || !this.owns(lane, ticket)) return false; + this.pending.delete(lane); + if (lane === "hello") this.publish({ ...this.state, canWrite: false, error: message }); + else if (lane === "mutation") { + this.pending.delete("list"); + this.pending.delete("detail"); + this.publish({ + ...this.state, + mutationPending: false, + list: stale(this.state.list), + detail: stale(this.state.detail), + displayed: false, + error: message, + }); + } else if (lane === "accounts") { + this.publish({ + ...this.state, + accounts: { ...this.state.accounts, status: "error", error: message }, + }); + } else if (lane === "list") { + this.publish({ + ...this.state, + list: { ...this.state.list, status: "error", error: message }, + }); + } else + this.publish({ + ...this.state, + detail: { status: "error", value: null, error: message }, + displayed: false, + }); + return true; + } +} + +/** Keep every page tied to the snapshot and branch actually being read. */ +export function libraryDetailPage( + detail: LibraryDetail, + page: Partial>, +): DetailRequest { + return { + kind: "detail", + key: detail.conversation.key, + snapshotId: detail.snapshotId, + ...(detail.nodeId === null ? {} : { nodeId: detail.nodeId }), + offset: page.offset ?? detail.offset, + snapshotOffset: page.snapshotOffset ?? detail.snapshotOffset, + branchOffset: page.branchOffset ?? detail.branchOffset, + showHidden: page.showHidden ?? detail.showHidden, + }; +} + +/** Conversation IDs are source-local; never use one alone as a rendered row key. */ +export function libraryReaderRowKey(binding: LibraryReaderBinding, row: LibrarySummary): string { + return JSON.stringify([binding.environmentId, binding.generation, row.accountId, row.key]); +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2daf804aa..19e0e884c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -7,6 +7,10 @@ ], "type": "module", "exports": { + "./conversationLibrary": { + "types": "./src/conversationLibrary.ts", + "import": "./src/conversationLibrary.ts" + }, ".": { "types": "./src/index.ts", "import": "./src/index.ts" diff --git a/packages/contracts/src/conversationLibrary.test.ts b/packages/contracts/src/conversationLibrary.test.ts new file mode 100644 index 000000000..ffcdf8185 --- /dev/null +++ b/packages/contracts/src/conversationLibrary.test.ts @@ -0,0 +1,134 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + CONVERSATION_LIBRARY_PROTOCOL, + LibraryErrorCodeSchema, + LibraryReplySchema, + LibraryRequestSchema, + type LibraryRequest, +} from "./conversationLibrary.ts"; + +const decodeRequest = Schema.decodeUnknownSync(LibraryRequestSchema); +const decodeReply = Schema.decodeUnknownSync(LibraryReplySchema); +const decodeErrorCode = Schema.decodeUnknownSync(LibraryErrorCodeSchema); + +describe("conversation library runtime contract", () => { + it("decodes every existing request discriminator and rejects malformed variants", () => { + const requests = [ + { kind: "hello" }, + { kind: "accounts" }, + { kind: "createAccount", label: "Personal", workspace: "local" }, + { kind: "preview", conversations: [{ title: "Sample", mapping: {} }] }, + { kind: "import", accountId: "account-1", conversations: [{ title: "Sample", mapping: {} }] }, + { kind: "list", view: "unread" }, + { kind: "detail", key: "conversation-1", offset: 0 }, + { kind: "update", key: "conversation-1", pinned: true }, + { + kind: "selectSnapshot", + key: "conversation-1", + snapshotId: "snapshot-1", + expectedRevision: 1, + }, + { kind: "remove", key: "conversation-1", expectedRevision: 1 }, + ] satisfies readonly LibraryRequest[]; + + expect(requests.map((request) => decodeRequest(request).kind)).toEqual( + requests.map((request) => request.kind), + ); + expect(() => decodeRequest({ kind: "import", accountId: 1, conversations: [] })).toThrow(); + expect(() => decodeRequest({ kind: "unknown" })).toThrow(); + }); + + it("decodes every existing reply discriminator and its nested record shapes", () => { + const replies = [ + { + kind: "hello", + protocol: CONVERSATION_LIBRARY_PROTOCOL, + revision: 1, + canWrite: false, + capture: "not-enabled", + }, + { kind: "accounts", accounts: [{ id: "account-1", label: "Personal", workspace: "local" }] }, + { kind: "account", account: { id: "account-1", label: "Personal", workspace: "local" } }, + { + kind: "preview", + conversations: [ + { id: "conversation-1", title: "Sample", messageCount: 2, warningCount: 0 }, + ], + }, + { kind: "imported", inserted: 1, duplicates: 0, older: 0, conflicts: 0, revision: 2 }, + { kind: "list", rows: [], revision: 2, cursor: null }, + { + kind: "detail", + conversation: { + key: "key-1", + accountId: "account-1", + conversationId: "conversation-1", + title: "Sample", + snapshotId: "snapshot-1", + sourceUpdatedAt: null, + importedAt: 1, + revision: 2, + unread: false, + pinned: false, + archived: false, + attention: false, + conflicts: false, + messageCount: 1, + warningCount: 0, + }, + account: { id: "account-1", label: "Personal", workspace: "local" }, + snapshotId: "snapshot-1", + snapshotSourceUpdatedAt: null, + snapshotImportedAt: 1, + showHidden: false, + nodeId: null, + messages: [], + totalMessages: 0, + readThrough: 0, + offset: 0, + previousOffset: null, + nextOffset: null, + snapshots: [], + snapshotOffset: 0, + snapshotCount: 1, + branches: [], + branchOffset: 0, + branchCount: 0, + warnings: [], + }, + { kind: "updated", revision: 3 }, + { kind: "removed", revision: 4 }, + ] as const; + + expect(replies.map((reply) => decodeReply(reply).kind)).toEqual( + replies.map((reply) => reply.kind), + ); + expect(() => + decodeReply({ + kind: "hello", + protocol: CONVERSATION_LIBRARY_PROTOCOL, + revision: 1, + capture: "not-enabled", + }), + ).toThrow(); + }); + + it("validates the known library error codes", () => { + expect( + ["invalid", "too-large", "not-found", "conflict", "unsupported", "storage", "forbidden"].map( + (code) => decodeErrorCode(code), + ), + ).toEqual([ + "invalid", + "too-large", + "not-found", + "conflict", + "unsupported", + "storage", + "forbidden", + ]); + expect(() => decodeErrorCode("private-detail")).toThrow(); + }); +}); diff --git a/packages/contracts/src/conversationLibrary.ts b/packages/contracts/src/conversationLibrary.ts new file mode 100644 index 000000000..d72d27e58 --- /dev/null +++ b/packages/contracts/src/conversationLibrary.ts @@ -0,0 +1,419 @@ +import * as Schema from "effect/Schema"; + +/** The library is a read model of supplied records, never a coding provider. */ +export const CONVERSATION_LIBRARY_PROTOCOL = "t3.conversation-library.v1"; +export const CONVERSATION_LIBRARY_PATH = "/api/conversation-library"; +export const LIBRARY_MAX_REQUEST_BYTES = 16 * 1024 * 1024; +export const LIBRARY_MAX_CONVERSATIONS = 1_000; +export const LIBRARY_MAX_NODES = 10_000; +export const LIBRARY_MAX_TEXT_BYTES = 8 * 1024 * 1024; +export const LIBRARY_PAGE_SIZE = 50; + +export interface ExportMessage { + readonly id?: string | null; + readonly author: { readonly role: string }; + readonly create_time?: number | null; + readonly content?: { + readonly content_type?: string; + readonly parts?: readonly unknown[]; + readonly text?: string; + } | null; + readonly metadata?: { readonly is_visually_hidden_from_conversation?: boolean }; +} + +export interface ExportNode { + readonly id?: string; + readonly parent?: string | null; + readonly message?: ExportMessage | null; +} + +export interface ExportConversation { + readonly id?: string; + readonly conversation_id?: string; + readonly title: string; + readonly create_time?: number | null; + readonly update_time?: number | null; + readonly current_node?: string | null; + readonly mapping: Readonly>; +} + +export interface LibraryNode { + readonly id: string; + readonly parentId: string | null; + readonly messageId: string | null; + readonly role: string | null; + readonly text: string; + readonly createdAt: number | null; + readonly hidden: boolean; + readonly unsupportedParts: number; +} + +export interface LibrarySnapshot { + readonly conversationId: string; + readonly title: string; + readonly sourceUpdatedAt: number | null; + readonly currentNodeId: string | null; + readonly nodes: readonly LibraryNode[]; + readonly warnings: readonly string[]; +} + +export interface LibraryAccount { + readonly id: string; + readonly label: string; + readonly workspace: string; +} + +export type LibraryView = "all" | "unread" | "pinned" | "archived" | "attention"; + +export interface LibrarySummary { + readonly key: string; + readonly accountId: string; + readonly conversationId: string; + readonly title: string; + readonly snapshotId: string; + readonly sourceUpdatedAt: number | null; + readonly importedAt: number; + readonly revision: number; + readonly unread: boolean; + readonly pinned: boolean; + readonly archived: boolean; + readonly attention: boolean; + readonly conflicts: boolean; + readonly messageCount: number; + readonly warningCount: number; +} + +export interface LibrarySnapshotSummary { + readonly id: string; + readonly sourceUpdatedAt: number | null; + readonly importedAt: number; + readonly messageCount: number; +} + +export type LibraryRequest = + | { readonly kind: "hello" } + | { readonly kind: "accounts" } + | { readonly kind: "createAccount"; readonly label: string; readonly workspace: string } + | { readonly kind: "preview"; readonly conversations: readonly ExportConversation[] } + | { + readonly kind: "import"; + readonly accountId: string; + readonly conversations: readonly ExportConversation[]; + } + | { + readonly kind: "list"; + readonly accountId?: string; + readonly query?: string; + readonly view?: LibraryView; + readonly cursor?: string; + } + | { + readonly kind: "detail"; + readonly key: string; + readonly snapshotId?: string; + readonly nodeId?: string; + readonly offset?: number; + readonly snapshotOffset?: number; + readonly branchOffset?: number; + readonly showHidden?: boolean; + } + | { + readonly kind: "update"; + readonly key: string; + readonly pinned?: boolean; + readonly archived?: boolean; + readonly attention?: boolean; + readonly readThrough?: number; + } + | { + readonly kind: "selectSnapshot"; + readonly key: string; + readonly snapshotId: string; + readonly expectedRevision: number; + } + | { readonly kind: "remove"; readonly key: string; readonly expectedRevision: number }; + +export interface LibraryDetail { + readonly kind: "detail"; + readonly conversation: LibrarySummary; + readonly account: LibraryAccount; + readonly snapshotId: string; + readonly snapshotSourceUpdatedAt: number | null; + readonly snapshotImportedAt: number; + readonly showHidden: boolean; + readonly nodeId: string | null; + readonly messages: readonly LibraryNode[]; + readonly totalMessages: number; + readonly readThrough: number; + readonly offset: number; + readonly previousOffset: number | null; + readonly nextOffset: number | null; + readonly snapshots: readonly LibrarySnapshotSummary[]; + readonly snapshotOffset: number; + readonly snapshotCount: number; + readonly branches: readonly { readonly id: string; readonly preview: string }[]; + readonly branchOffset: number; + readonly branchCount: number; + readonly warnings: readonly string[]; +} + +export type LibraryReply = + | { + readonly kind: "hello"; + readonly protocol: typeof CONVERSATION_LIBRARY_PROTOCOL; + readonly revision: number; + readonly canWrite: boolean; + readonly capture: "not-enabled"; + } + | { readonly kind: "accounts"; readonly accounts: readonly LibraryAccount[] } + | { readonly kind: "account"; readonly account: LibraryAccount } + | { + readonly kind: "preview"; + readonly conversations: readonly { + readonly id: string; + readonly title: string; + readonly messageCount: number; + readonly warningCount: number; + }[]; + } + | { + readonly kind: "imported"; + readonly inserted: number; + readonly duplicates: number; + readonly older: number; + readonly conflicts: number; + readonly revision: number; + } + | { + readonly kind: "list"; + readonly rows: readonly LibrarySummary[]; + readonly revision: number; + readonly cursor: string | null; + } + | LibraryDetail + | { readonly kind: "updated"; readonly revision: number } + | { readonly kind: "removed"; readonly revision: number }; + +export type LibraryErrorCode = + | "invalid" + | "too-large" + | "not-found" + | "conflict" + | "unsupported" + | "storage" + | "forbidden"; + +const ExportMessageSchema = Schema.Struct({ + id: Schema.optionalKey(Schema.NullOr(Schema.String)), + author: Schema.Struct({ role: Schema.String }), + create_time: Schema.optionalKey(Schema.NullOr(Schema.Number)), + content: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + content_type: Schema.optionalKey(Schema.String), + parts: Schema.optionalKey(Schema.Array(Schema.Unknown)), + text: Schema.optionalKey(Schema.String), + }), + ), + ), + metadata: Schema.optionalKey( + Schema.Struct({ + is_visually_hidden_from_conversation: Schema.optionalKey(Schema.Boolean), + }), + ), +}); + +const ExportNodeSchema = Schema.Struct({ + id: Schema.optionalKey(Schema.String), + parent: Schema.optionalKey(Schema.NullOr(Schema.String)), + message: Schema.optionalKey(Schema.NullOr(ExportMessageSchema)), +}); + +const ExportConversationSchema = Schema.Struct({ + id: Schema.optionalKey(Schema.String), + conversation_id: Schema.optionalKey(Schema.String), + title: Schema.String, + create_time: Schema.optionalKey(Schema.NullOr(Schema.Number)), + update_time: Schema.optionalKey(Schema.NullOr(Schema.Number)), + current_node: Schema.optionalKey(Schema.NullOr(Schema.String)), + mapping: Schema.Record(Schema.String, ExportNodeSchema), +}); + +const LibraryNodeSchema = Schema.Struct({ + id: Schema.String, + parentId: Schema.NullOr(Schema.String), + messageId: Schema.NullOr(Schema.String), + role: Schema.NullOr(Schema.String), + text: Schema.String, + createdAt: Schema.NullOr(Schema.Number), + hidden: Schema.Boolean, + unsupportedParts: Schema.Number, +}); + +const LibraryAccountSchema = Schema.Struct({ + id: Schema.String, + label: Schema.String, + workspace: Schema.String, +}); + +export const LibraryViewSchema = Schema.Literals([ + "all", + "unread", + "pinned", + "archived", + "attention", +]); + +const LibrarySummarySchema = Schema.Struct({ + key: Schema.String, + accountId: Schema.String, + conversationId: Schema.String, + title: Schema.String, + snapshotId: Schema.String, + sourceUpdatedAt: Schema.NullOr(Schema.Number), + importedAt: Schema.Number, + revision: Schema.Number, + unread: Schema.Boolean, + pinned: Schema.Boolean, + archived: Schema.Boolean, + attention: Schema.Boolean, + conflicts: Schema.Boolean, + messageCount: Schema.Number, + warningCount: Schema.Number, +}); + +const LibrarySnapshotSummarySchema = Schema.Struct({ + id: Schema.String, + sourceUpdatedAt: Schema.NullOr(Schema.Number), + importedAt: Schema.Number, + messageCount: Schema.Number, +}); + +const LibraryDetailSchema = Schema.Struct({ + kind: Schema.Literal("detail"), + conversation: LibrarySummarySchema, + account: LibraryAccountSchema, + snapshotId: Schema.String, + snapshotSourceUpdatedAt: Schema.NullOr(Schema.Number), + snapshotImportedAt: Schema.Number, + showHidden: Schema.Boolean, + nodeId: Schema.NullOr(Schema.String), + messages: Schema.Array(LibraryNodeSchema), + totalMessages: Schema.Number, + readThrough: Schema.Number, + offset: Schema.Number, + previousOffset: Schema.NullOr(Schema.Number), + nextOffset: Schema.NullOr(Schema.Number), + snapshots: Schema.Array(LibrarySnapshotSummarySchema), + snapshotOffset: Schema.Number, + snapshotCount: Schema.Number, + branches: Schema.Array(Schema.Struct({ id: Schema.String, preview: Schema.String })), + branchOffset: Schema.Number, + branchCount: Schema.Number, + warnings: Schema.Array(Schema.String), +}); + +export const LibraryRequestSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("hello") }), + Schema.Struct({ kind: Schema.Literal("accounts") }), + Schema.Struct({ + kind: Schema.Literal("createAccount"), + label: Schema.String, + workspace: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("preview"), + conversations: Schema.Array(ExportConversationSchema), + }), + Schema.Struct({ + kind: Schema.Literal("import"), + accountId: Schema.String, + conversations: Schema.Array(ExportConversationSchema), + }), + Schema.Struct({ + kind: Schema.Literal("list"), + accountId: Schema.optionalKey(Schema.String), + query: Schema.optionalKey(Schema.String), + view: Schema.optionalKey(LibraryViewSchema), + cursor: Schema.optionalKey(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("detail"), + key: Schema.String, + snapshotId: Schema.optionalKey(Schema.String), + nodeId: Schema.optionalKey(Schema.String), + offset: Schema.optionalKey(Schema.Number), + snapshotOffset: Schema.optionalKey(Schema.Number), + branchOffset: Schema.optionalKey(Schema.Number), + showHidden: Schema.optionalKey(Schema.Boolean), + }), + Schema.Struct({ + kind: Schema.Literal("update"), + key: Schema.String, + pinned: Schema.optionalKey(Schema.Boolean), + archived: Schema.optionalKey(Schema.Boolean), + attention: Schema.optionalKey(Schema.Boolean), + readThrough: Schema.optionalKey(Schema.Number), + }), + Schema.Struct({ + kind: Schema.Literal("selectSnapshot"), + key: Schema.String, + snapshotId: Schema.String, + expectedRevision: Schema.Number, + }), + Schema.Struct({ + kind: Schema.Literal("remove"), + key: Schema.String, + expectedRevision: Schema.Number, + }), +]) satisfies Schema.Schema; + +export const LibraryReplySchema = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("hello"), + protocol: Schema.Literal(CONVERSATION_LIBRARY_PROTOCOL), + revision: Schema.Number, + canWrite: Schema.Boolean, + capture: Schema.Literal("not-enabled"), + }), + Schema.Struct({ kind: Schema.Literal("accounts"), accounts: Schema.Array(LibraryAccountSchema) }), + Schema.Struct({ kind: Schema.Literal("account"), account: LibraryAccountSchema }), + Schema.Struct({ + kind: Schema.Literal("preview"), + conversations: Schema.Array( + Schema.Struct({ + id: Schema.String, + title: Schema.String, + messageCount: Schema.Number, + warningCount: Schema.Number, + }), + ), + }), + Schema.Struct({ + kind: Schema.Literal("imported"), + inserted: Schema.Number, + duplicates: Schema.Number, + older: Schema.Number, + conflicts: Schema.Number, + revision: Schema.Number, + }), + Schema.Struct({ + kind: Schema.Literal("list"), + rows: Schema.Array(LibrarySummarySchema), + revision: Schema.Number, + cursor: Schema.NullOr(Schema.String), + }), + LibraryDetailSchema, + Schema.Struct({ kind: Schema.Literal("updated"), revision: Schema.Number }), + Schema.Struct({ kind: Schema.Literal("removed"), revision: Schema.Number }), +]) satisfies Schema.Schema; + +export const LibraryErrorCodeSchema = Schema.Literals([ + "invalid", + "too-large", + "not-found", + "conflict", + "unsupported", + "storage", + "forbidden", +]) satisfies Schema.Schema; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index ac7047c32..169ed1d10 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -5,6 +5,7 @@ import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; +import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -58,6 +59,13 @@ import { RelayLinkProofRequest, } from "./relay.ts"; +import { + CONVERSATION_LIBRARY_PATH, + LibraryErrorCodeSchema, + LibraryReplySchema, + LibraryRequestSchema, +} from "./conversationLibrary.ts"; + const OptionalBearerHeaders = Schema.Struct({ authorization: Schema.optionalKey(Schema.String), dpop: Schema.optionalKey(Schema.String), @@ -356,6 +364,73 @@ const EnvironmentProjectMutationErrors = [ EnvironmentInternalError, ] as const; +const EnvironmentConversationLibraryInvalidError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("invalid"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(400)); + +const EnvironmentConversationLibraryForbiddenError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("forbidden"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(403)); + +const EnvironmentConversationLibraryNotFoundError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("not-found"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(404)); + +const EnvironmentConversationLibraryConflictError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("conflict"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(409)); + +const EnvironmentConversationLibraryTooLargeError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("too-large"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(413)); + +const EnvironmentConversationLibraryStorageError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("storage"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(500)); + +const EnvironmentConversationLibraryUnsupportedError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("unsupported"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(501)); + +const EnvironmentConversationLibraryErrorSchemas = [ + EnvironmentConversationLibraryInvalidError, + EnvironmentConversationLibraryForbiddenError, + EnvironmentConversationLibraryNotFoundError, + EnvironmentConversationLibraryConflictError, + EnvironmentConversationLibraryTooLargeError, + EnvironmentConversationLibraryStorageError, + EnvironmentConversationLibraryUnsupportedError, +] as const; + +export const EnvironmentConversationLibraryErrorSchema = Schema.Union( + EnvironmentConversationLibraryErrorSchemas, +); +export type EnvironmentConversationLibraryError = + typeof EnvironmentConversationLibraryErrorSchema.Type; + +export const EnvironmentConversationLibraryErrorCode = LibraryErrorCodeSchema; + export interface EnvironmentSessionPrincipalShape { readonly sessionId: AuthSessionId; readonly subject: string; @@ -651,10 +726,22 @@ class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") }), ) {} +export class EnvironmentConversationLibraryHttpApi extends HttpApiGroup.make( + "conversationLibrary", +).add( + HttpApiEndpoint.post("conversationLibrary", CONVERSATION_LIBRARY_PATH, { + headers: OptionalBearerHeaders, + payload: LibraryRequestSchema, + success: LibraryReplySchema, + error: [...EnvironmentConversationLibraryErrorSchemas, EnvironmentScopeRequiredError], + }).middleware(EnvironmentAuthenticatedAuth), +) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) .add(EnvironmentPullRequestsHttpApi) .add(EnvironmentProjectsHttpApi) - .add(EnvironmentConnectHttpApi) {} + .add(EnvironmentConnectHttpApi) + .add(EnvironmentConversationLibraryHttpApi) {} diff --git a/packages/shared/package.json b/packages/shared/package.json index b67a205e5..17bb2f5aa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./conversationLibrary": { + "types": "./src/conversationLibrary.ts", + "import": "./src/conversationLibrary.ts" + }, "./orchestrationV2ThreadError": { "types": "./src/orchestrationV2ThreadError.ts", "import": "./src/orchestrationV2ThreadError.ts" diff --git a/packages/shared/src/conversationLibrary.cases.ts b/packages/shared/src/conversationLibrary.cases.ts new file mode 100644 index 000000000..0b6248145 --- /dev/null +++ b/packages/shared/src/conversationLibrary.cases.ts @@ -0,0 +1,317 @@ +import * as NodeAssert from "node:assert/strict"; +import type { ExportConversation } from "@t3tools/contracts/conversationLibrary"; +import { + LibraryRequestFence, + libraryBranch, + libraryBranchIds, + libraryOriginalUrl, + libraryRequestMutates, + normalizeConversationExport, +} from "./conversationLibrary.ts"; + +function sampleConversation(): ExportConversation { + return { + id: "chat-one", + title: "A sample conversation", + update_time: 1_700_000_000, + current_node: "a", + mapping: { + root: { id: "root", parent: null, message: null }, + u: { + id: "u", + parent: "root", + message: { + id: "user-id", + author: { role: "user" }, + content: { content_type: "text", parts: ["Question"] }, + }, + }, + a: { + id: "a", + parent: "u", + message: { + id: "answer-id", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Answer"] }, + }, + }, + b: { + id: "b", + parent: "u", + message: { + id: "other-answer-id", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Regenerated answer"] }, + }, + }, + }, + }; +} + +export const conversationLibraryCases: readonly { + readonly name: string; + readonly run: () => void; +}[] = [ + { + name: "preserves the selected path and regenerated alternatives", + run: () => { + const snapshot = normalizeConversationExport([sampleConversation()])[0]!; + NodeAssert.deepEqual( + libraryBranch(snapshot.nodes, snapshot.currentNodeId).map((n) => n.text), + ["Question", "Answer"], + ); + NodeAssert.deepEqual( + libraryBranch(snapshot.nodes, "b").map((n) => n.text), + ["Question", "Regenerated answer"], + ); + NodeAssert.deepEqual(libraryBranchIds(snapshot.nodes, snapshot.currentNodeId), ["a", "b"]); + }, + }, + { + name: "canonicalizes mapping order without merging distinct nodes", + run: () => { + const sample = sampleConversation(); + const reordered = { + ...sample, + mapping: Object.fromEntries(Object.entries(sample.mapping).reverse()), + }; + NodeAssert.equal( + JSON.stringify(normalizeConversationExport([sample])), + JSON.stringify(normalizeConversationExport([reordered])), + ); + }, + }, + { + name: "rejects a cycle outside the selected path", + run: () => { + const sample = sampleConversation(); + const mapping = { ...sample.mapping, x: { parent: "y" }, y: { parent: "x" } }; + NodeAssert.throws(() => normalizeConversationExport([{ ...sample, mapping }]), /cycle/); + }, + }, + { + name: "retains incomplete branches with an explicit gap", + run: () => { + const sample = sampleConversation(); + const mapping = { ...sample.mapping, u: { ...sample.mapping.u!, parent: "missing" } }; + const snapshot = normalizeConversationExport([{ ...sample, mapping }])[0]!; + NodeAssert.match(snapshot.warnings.join(" "), /history gap/); + NodeAssert.equal(libraryBranch(snapshot.nodes, "a").length, 2); + }, + }, + { + name: "does not choose another branch when the exported selection is missing", + run: () => { + const snapshot = normalizeConversationExport([ + { ...sampleConversation(), current_node: "unknown" }, + ])[0]!; + NodeAssert.equal(snapshot.currentNodeId, null); + NodeAssert.deepEqual(libraryBranch(snapshot.nodes, null), []); + NodeAssert.throws(() => libraryBranch(snapshot.nodes, "unknown"), /not in this snapshot/); + }, + }, + { + name: "reports unsupported image and tool parts without extracting their URLs", + run: () => { + const snapshot = normalizeConversationExport([ + { + id: "media", + title: "Media", + current_node: "one", + mapping: { + one: { + message: { + author: { role: "assistant" }, + content: { + parts: ["Visible text", { asset_pointer: "https://example.invalid/private" }], + }, + }, + }, + }, + }, + ])[0]!; + NodeAssert.equal(snapshot.nodes[0]!.text, "Visible text"); + NodeAssert.equal(snapshot.nodes[0]!.unsupportedParts, 1); + NodeAssert.match(snapshot.warnings.join(" "), /non-text parts/); + NodeAssert.ok(!JSON.stringify(snapshot).includes("private")); + }, + }, + { + name: "keeps hidden exported messages without displaying them by default", + run: () => { + const snapshot = normalizeConversationExport([ + { + id: "hidden", + title: "Hidden", + current_node: "one", + mapping: { + one: { + message: { + author: { role: "system" }, + metadata: { is_visually_hidden_from_conversation: true }, + content: { parts: ["Hidden text"] }, + }, + }, + }, + }, + ])[0]!; + NodeAssert.equal(libraryBranch(snapshot.nodes, "one").length, 0); + NodeAssert.equal(libraryBranch(snapshot.nodes, "one", true).length, 1); + }, + }, + { + name: "rejects duplicate conversation identities in a batch", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([sampleConversation(), sampleConversation()]), + /twice/, + ); + }, + }, + { + name: "rejects contradictory aliases and mismatched mapping IDs", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([{ ...sampleConversation(), conversation_id: "other" }]), + /conflicting/, + ); + NodeAssert.throws( + () => + normalizeConversationExport([ + { ...sampleConversation(), mapping: { key: { id: "different" } } }, + ]), + /mapping key/, + ); + }, + }, + { + name: "never normalizes whitespace into a different identity", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([{ ...sampleConversation(), id: " chat-one" }]), + /whitespace/, + ); + NodeAssert.throws( + () => + normalizeConversationExport([ + { ...sampleConversation(), mapping: { " a": { parent: null } } }, + ]), + /whitespace/, + ); + }, + }, + { + name: "validates timestamps instead of substituting import time", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([{ ...sampleConversation(), update_time: Infinity }]), + /timestamp/, + ); + NodeAssert.equal( + normalizeConversationExport([{ ...sampleConversation(), update_time: null }])[0]! + .sourceUpdatedAt, + null, + ); + }, + }, + { + name: "bounds node count and text bytes before admission", + run: () => { + NodeAssert.throws( + () => + normalizeConversationExport([ + { + ...sampleConversation(), + mapping: Object.fromEntries( + Array.from({ length: 10_001 }, (_, n) => [String(n), { parent: null }]), + ), + }, + ]), + /exceeds/, + ); + const big = "x".repeat(8 * 1024 * 1024 + 1); + NodeAssert.throws( + () => + normalizeConversationExport([ + { + ...sampleConversation(), + mapping: { + a: { message: { author: { role: "assistant" }, content: { parts: [big] } } }, + }, + }, + ]), + /text budget/, + ); + }, + }, + { + name: "constructs only fixed-origin original-chat links", + run: () => { + NodeAssert.equal(libraryOriginalUrl("chat-one"), "https://chatgpt.com/c/chat-one"); + for (const id of ["../logout", "https://evil.invalid", "a?token=secret", "a#x", ""]) + NodeAssert.equal(libraryOriginalUrl(id), null); + }, + }, + { + name: "invalidates delayed replies when the selected context changes", + run: () => { + const fence = new LibraryRequestFence(); + const accountA = fence.next(); + const accountB = fence.next(); + NodeAssert.equal(fence.accepts(accountA), false); + NodeAssert.equal(fence.accepts(accountB), true); + fence.next(); + NodeAssert.equal(fence.accepts(accountB), false); + }, + }, + { + name: "requires mutation authority for every state-changing request", + run: () => { + NodeAssert.equal( + libraryRequestMutates({ kind: "import", accountId: "a", conversations: [] }), + true, + ); + NodeAssert.equal( + libraryRequestMutates({ kind: "createAccount", label: "a", workspace: "Personal" }), + true, + ); + NodeAssert.equal(libraryRequestMutates({ kind: "update", key: "a", pinned: true }), true); + NodeAssert.equal( + libraryRequestMutates({ + kind: "selectSnapshot", + key: "a", + snapshotId: "b", + expectedRevision: 1, + }), + true, + ); + NodeAssert.equal( + libraryRequestMutates({ kind: "remove", key: "a", expectedRevision: 1 }), + true, + ); + for (const request of [ + { kind: "hello" }, + { kind: "accounts" }, + { kind: "list" }, + { kind: "detail", key: "a" }, + { kind: "preview", conversations: [] }, + ] as const) + NodeAssert.equal(libraryRequestMutates(request), false); + }, + }, + { + name: "handles prototype-looking node IDs as ordinary map keys", + run: () => { + const mapping = Object.fromEntries([ + [ + "__proto__", + { parent: null, message: { author: { role: "user" }, content: { parts: ["Safe"] } } }, + ], + ]); + const snapshot = normalizeConversationExport([ + { id: "prototype", title: "Prototype", current_node: "__proto__", mapping }, + ])[0]!; + NodeAssert.equal(libraryBranch(snapshot.nodes, "__proto__")[0]!.text, "Safe"); + }, + }, +]; diff --git a/packages/shared/src/conversationLibrary.test.ts b/packages/shared/src/conversationLibrary.test.ts new file mode 100644 index 000000000..0bdd97c23 --- /dev/null +++ b/packages/shared/src/conversationLibrary.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vite-plus/test"; +import { conversationLibraryCases } from "./conversationLibrary.cases.ts"; + +describe("conversation library", () => { + for (const test of conversationLibraryCases) it(test.name, test.run); +}); diff --git a/packages/shared/src/conversationLibrary.ts b/packages/shared/src/conversationLibrary.ts new file mode 100644 index 000000000..d8cc38267 --- /dev/null +++ b/packages/shared/src/conversationLibrary.ts @@ -0,0 +1,243 @@ +import { + LIBRARY_MAX_CONVERSATIONS, + LIBRARY_MAX_NODES, + LIBRARY_MAX_TEXT_BYTES, + type ExportConversation, + type LibraryNode, + type LibrarySnapshot, + type LibraryErrorCode, + type LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; + +export class ConversationLibraryError extends Error { + readonly code: LibraryErrorCode; + + constructor(code: LibraryErrorCode, message: string) { + super(message); + this.code = code; + this.name = "ConversationLibraryError"; + } +} + +export function boundedLibraryString(value: string, name: string, max = 512): string { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > max || /[\u0000-\u001f\u007f]/.test(trimmed)) { + throw new ConversationLibraryError( + "invalid", + `${name} is empty, too long, or contains control characters.`, + ); + } + return trimmed; +} + +function identifier(value: string, name: string): string { + const checked = boundedLibraryString(value, name); + if (checked !== value) + throw new ConversationLibraryError("invalid", `${name} contains surrounding whitespace.`); + return value; +} + +function exportTime(value: number | null | undefined): number | null { + if (value === undefined || value === null) return null; + const milliseconds = value * 1_000; + if (!Number.isFinite(milliseconds) || Math.abs(milliseconds) > 8_640_000_000_000_000) { + throw new ConversationLibraryError("invalid", "An export contains an invalid timestamp."); + } + return Math.trunc(milliseconds); +} + +function compareIds(a: { readonly id: string }, b: { readonly id: string }): number { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** Structural decoding belongs at the boundary; this checks graph and size invariants. */ +export function normalizeConversationExport( + input: readonly ExportConversation[], +): readonly LibrarySnapshot[] { + if (input.length === 0 || input.length > LIBRARY_MAX_CONVERSATIONS) { + throw new ConversationLibraryError( + "too-large", + `Import between 1 and ${LIBRARY_MAX_CONVERSATIONS} conversations at a time.`, + ); + } + const ids = new Set(); + const encoder = new TextEncoder(); + let totalBytes = 0; + let totalNodes = 0; + return input.map((conversation) => { + const id = identifier(conversation.id ?? conversation.conversation_id ?? "", "Conversation ID"); + if ( + conversation.id && + conversation.conversation_id && + conversation.id !== conversation.conversation_id + ) { + throw new ConversationLibraryError( + "invalid", + "The export contains conflicting conversation IDs.", + ); + } + if (ids.has(id)) + throw new ConversationLibraryError( + "conflict", + "The same conversation occurs twice in one import. Import the snapshots separately.", + ); + ids.add(id); + const title = boundedLibraryString( + conversation.title || "Untitled conversation", + "Title", + 2_000, + ); + const entries = Object.entries(conversation.mapping); + totalNodes += entries.length; + if (totalNodes > 20_000) + throw new ConversationLibraryError("too-large", "Import at most 20,000 nodes at a time."); + if (entries.length > LIBRARY_MAX_NODES) { + throw new ConversationLibraryError( + "too-large", + `A conversation exceeds ${LIBRARY_MAX_NODES} nodes.`, + ); + } + const nodes = entries + .map(([key, entry]): LibraryNode => { + identifier(key, "Node ID"); + if (entry.id !== undefined && entry.id !== key) { + throw new ConversationLibraryError( + "invalid", + "A node ID differs from its export mapping key.", + ); + } + const message = entry.message; + const content = message?.content; + const parts = content?.parts ?? []; + const textParts = parts.filter((part): part is string => typeof part === "string"); + const text = textParts.length > 0 ? textParts.join("\n") : (content?.text ?? ""); + const unsupportedParts = + parts.length - + textParts.length + + (content && !parts.length && !content.text && content.content_type !== "text" ? 1 : 0); + totalBytes += encoder.encode(text).byteLength; + if (totalBytes > LIBRARY_MAX_TEXT_BYTES) { + throw new ConversationLibraryError( + "too-large", + "The import exceeds the text budget. Split the supplied export into smaller imports.", + ); + } + return { + id: key, + parentId: entry.parent == null ? null : identifier(entry.parent, "Parent ID"), + messageId: message?.id == null ? null : identifier(message.id, "Message ID"), + role: message ? boundedLibraryString(message.author.role, "Author role", 128) : null, + text, + createdAt: exportTime(message?.create_time), + hidden: message?.metadata?.is_visually_hidden_from_conversation === true, + unsupportedParts, + }; + }) + .sort(compareIds); + const byId = new Map(nodes.map((node) => [node.id, node])); + const finished = new Set(); + let missingParents = 0; + for (const node of nodes) { + if (node.parentId !== null && !byId.has(node.parentId)) missingParents++; + const visiting = new Set(); + let cursor: LibraryNode | undefined = node; + while (cursor && !finished.has(cursor.id)) { + if (visiting.has(cursor.id)) + throw new ConversationLibraryError( + "invalid", + "The export contains a cycle in its conversation graph.", + ); + visiting.add(cursor.id); + cursor = cursor.parentId === null ? undefined : byId.get(cursor.parentId); + } + for (const visited of visiting) finished.add(visited); + } + const currentNodeId = + conversation.current_node && byId.has(conversation.current_node) + ? conversation.current_node + : null; + const warnings: string[] = []; + if (missingParents) + warnings.push( + `${missingParents} parent references are absent; those branches have a history gap.`, + ); + if (conversation.current_node && !currentNodeId) + warnings.push("The exported selected node is absent. Select a retained branch explicitly."); + if (!conversation.current_node && nodes.length) + warnings.push( + "The export does not identify its selected branch. Select a retained branch explicitly.", + ); + const unsupported = nodes.reduce((sum, node) => sum + node.unsupportedParts, 0); + if (unsupported) + warnings.push( + `${unsupported} non-text parts are represented as unavailable, not downloaded or executed.`, + ); + return { + conversationId: id, + title, + sourceUpdatedAt: exportTime(conversation.update_time), + currentNodeId, + nodes, + warnings, + }; + }); +} + +export function libraryBranchIds( + nodes: readonly LibraryNode[], + selected: string | null, +): readonly string[] { + const parents = new Set(nodes.flatMap((node) => (node.parentId === null ? [] : [node.parentId]))); + const leaves = nodes.filter((node) => !parents.has(node.id)).map((node) => node.id); + return selected && !leaves.includes(selected) ? [selected, ...leaves] : leaves; +} + +/** An explicit node selection never falls back to some other branch. */ +export function libraryBranch( + nodes: readonly LibraryNode[], + nodeId: string | null, + showHidden = false, +): readonly LibraryNode[] { + if (nodeId === null) return []; + const byId = new Map(nodes.map((node) => [node.id, node])); + if (!byId.has(nodeId)) + throw new ConversationLibraryError("not-found", "The selected branch is not in this snapshot."); + const seen = new Set(); + const path: LibraryNode[] = []; + let cursor = byId.get(nodeId); + while (cursor) { + if (seen.has(cursor.id)) + throw new ConversationLibraryError("invalid", "The retained graph contains a cycle."); + seen.add(cursor.id); + if (cursor.role !== null && (showHidden || !cursor.hidden)) path.push(cursor); + cursor = cursor.parentId === null ? undefined : byId.get(cursor.parentId); + } + return path.reverse(); +} + +export function libraryRequestMutates(request: LibraryRequest): boolean { + return ( + request.kind === "createAccount" || + request.kind === "import" || + request.kind === "update" || + request.kind === "selectSnapshot" || + request.kind === "remove" + ); +} + +export function libraryOriginalUrl(conversationId: string): string | null { + return /^[A-Za-z0-9_-]{1,512}$/.test(conversationId) + ? `https://chatgpt.com/c/${encodeURIComponent(conversationId)}` + : null; +} + +/** Reusing an epoch is a rendering bug: account and page changes invalidate pending reads. */ +export class LibraryRequestFence { + private generation = 0; + next(): number { + return ++this.generation; + } + accepts(generation: number): boolean { + return this.generation === generation; + } +} From 3aa46c43f4ec37b948c67afccc9179cbc38f6d7d Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 04:17:43 +0200 Subject: [PATCH 15/59] feat(contracts): restore guarded native creation contracts and storage --- .../NativeCreationAuthority.test.ts | 377 ++++++++++++ .../nativeCreation/NativeCreationAuthority.ts | 254 ++++++++ .../NativeCreationPreparation.test.ts | 192 ++++++ .../NativeCreationPreparation.ts | 315 ++++++++++ .../NativeCreationRepository.test.ts | 570 ++++++++++++++++++ .../NativeCreationRepository.ts | 124 ++++ .../NativeCreationRepositoryMigration.test.ts | 145 +++++ .../NativeCreationRepositorySqlite.ts | 406 +++++++++++++ knip.jsonc | 5 + packages/contracts/src/environment.ts | 21 + packages/contracts/src/index.ts | 1 + packages/contracts/src/nativeCreation.test.ts | 402 ++++++++++++ packages/contracts/src/nativeCreation.ts | 275 +++++++++ .../contracts/src/orchestrationDispatch.ts | 2 + packages/contracts/src/orchestrationV2.ts | 3 + 15 files changed, 3092 insertions(+) create mode 100644 apps/server/src/nativeCreation/NativeCreationAuthority.test.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationAuthority.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationPreparation.test.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationPreparation.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationRepository.test.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationRepository.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationRepositoryMigration.test.ts create mode 100644 apps/server/src/nativeCreation/NativeCreationRepositorySqlite.ts create mode 100644 packages/contracts/src/nativeCreation.test.ts create mode 100644 packages/contracts/src/nativeCreation.ts diff --git a/apps/server/src/nativeCreation/NativeCreationAuthority.test.ts b/apps/server/src/nativeCreation/NativeCreationAuthority.test.ts new file mode 100644 index 000000000..7154c0d68 --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationAuthority.test.ts @@ -0,0 +1,377 @@ +import { assert, it } from "@effect/vitest"; +import { + AuthSessionId, + NativeCreationHistoricalBinding, + ThreadId, + EventId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; +import migration from "../persistence/Migrations/003_JonesNativeCreationIntents.ts"; +import * as RepositorySqlite from "./NativeCreationRepositorySqlite.ts"; + +import * as Authority from "./NativeCreationAuthority.ts"; +import { + NativePreparationBinding, + nativeCreationCanonicalJson, + nativeCreationSha256, + nativePreparationCommand, + validateNativeCreationPreparation, +} from "./NativeCreationPreparation.ts"; + +const actorSessionId = AuthSessionId.make("fixture-session"); +const decodeFixtureBinding = Schema.decodeUnknownSync(NativePreparationBinding); +const decodeFixtureHistory = Schema.decodeUnknownSync(NativeCreationHistoricalBinding); +const decodeFixtureSession = Schema.decodeUnknownSync(AuthSessions.AuthSessionRecord); +const memory = NodeSqliteClient.layer({ filename: ":memory:" }); +const database = Layer.effectDiscard(migration).pipe(Layer.provideMerge(memory)); +const repositoryLayer = RepositorySqlite.layer.pipe(Layer.provideMerge(database)); +const guard = { + schema: "t3.native-creation-guard/v1" as const, + grantId: "fixture-grant", + grantRevision: 1, +}; +const fixture = Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-02T12:00:00Z"))); + const binding = decodeFixtureBinding({ + backend_instance: "fixture-backend", + environment_id: "fixture-environment", + project_id: "fixture-project", + project_cwd: "/fixture/project", + account_ref: "fixture-account", + runtime_mode: "full-access" as const, + interaction_mode: "default" as const, + base_branch: "main", + start_from_origin: false, + run_setup_script: false, + provider_model_selection: { instanceId: "codex", model: "fixture-model" }, + }); + const command = nativePreparationCommand( + "fixture-authority", + binding, + "Synthetic prompt", + "Synthetic thread", + "2026-10-02T12:34:56Z", + ); + const preparation = yield* validateNativeCreationPreparation( + new TextEncoder().encode( + nativeCreationCanonicalJson({ + schema: "voice.t3-bootstrap-preparation/v1", + operation_id: "fixture-authority", + binding, + command, + preparation_id: command.commandId.replace("voice-command-", "voice-bootstrap-"), + binding_digest: nativeCreationSha256(nativeCreationCanonicalJson(binding)), + prompt_digest: nativeCreationSha256(command.message.text), + command_digest: nativeCreationSha256(nativeCreationCanonicalJson(command)), + }), + ), + ); + const historical = decodeFixtureHistory({ + backendInstance: binding.backend_instance, + environmentId: binding.environment_id, + projectId: binding.project_id, + projectCwd: binding.project_cwd, + accountRef: binding.account_ref, + accountBindingId: "fixture-qualified-account", + accountBindingRevision: 1, + providerModelSelection: binding.provider_model_selection, + runtimeMode: binding.runtime_mode, + interactionMode: binding.interaction_mode, + baseBranch: binding.base_branch, + startFromOrigin: false, + runSetupScript: false, + requestedBranch: command.bootstrap.prepareWorktree.branch, + }); + const resources = { + projectCwd: binding.project_cwd, + branch: historical.requestedBranch, + worktreePath: "/fixture/worktree", + }; + const session = decodeFixtureSession({ + sessionId: actorSessionId, + subject: "Synthetic actor", + scopes: ["orchestration:operate"], + method: "bearer-access-token", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "bot", + os: null, + browser: null, + }, + issuedAt: "2026-01-01T00:00:00Z", + expiresAt: "2099-01-01T00:00:00Z", + revokedAt: null, + lastConnectedAt: null, + }); + const grant: Authority.NativeCreationGrant = { + grantId: guard.grantId, + revision: 1, + actorSessionId, + issuerId: "fixture-issuer", + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00Z"), + revoked: false, + operationId: preparation.operationId, + preparationId: preparation.preparationId, + preparationSha256: preparation.preparationSha256, + bindingDigest: preparation.bindingDigest, + binding: historical, + resources, + allowedStages: ["claim", "normalization", "fetch", "cleanup"], + recoveryScopes: [ + { + scopeId: "fixture-recovery", + resource: { + kind: "thread", + threadId: ThreadId.make(preparation.command.threadId), + incarnation: { eventId: EventId.make("fixture-created-event"), sequence: 1 }, + }, + }, + ], + }; + return { preparation, historical, resources, session, grant }; +}); + +const sessions = (read: () => Effect.Effect>) => + Layer.succeed( + AuthSessions.AuthSessionRepository, + AuthSessions.AuthSessionRepository.of({ + getById: () => read(), + create: () => Effect.void, + createReplacingActive: () => Effect.succeed([]), + createIfAbsent: () => Effect.void, + listActive: () => Effect.succeed([]), + revoke: () => Effect.succeed(false), + revokeAllExcept: () => Effect.succeed([]), + setLastConnectedAt: () => Effect.void, + setClientConnection: () => Effect.void, + }), + ); + +it.effect("production ports remain unavailable even with a current native operating session", () => + Effect.gen(function* () { + const value = yield* fixture; + const result = yield* Effect.gen(function* () { + const authority = yield* Authority.NativeCreationAuthority; + const sql = yield* SqlClient.SqlClient; + assert.isFalse(yield* authority.isAutomationEnrolled(actorSessionId)); + yield* sql`INSERT INTO native_creation_automation_enrollments (session_id, enrolled_at) + VALUES (${actorSessionId}, '2026-10-02T12:00:00Z')`; + assert.isTrue(yield* authority.isAutomationEnrolled(actorSessionId)); + const rejection = yield* authority + .authorize({ + actorSessionId, + guard, + preparation: value.preparation, + resources: value.resources, + stage: "claim", + }) + .pipe(Effect.flip); + assert.deepEqual(yield* sql`SELECT COUNT(*) AS count FROM native_creation_intents`, [ + { count: 0 }, + ]); + assert.deepEqual(yield* sql`SELECT COUNT(*) AS count FROM native_creation_effect_facts`, [ + { count: 0 }, + ]); + assert.isTrue(yield* authority.isAutomationEnrolled(actorSessionId)); + return rejection; + }).pipe( + Effect.provide( + Authority.NativeCreationAuthorityUnavailable.pipe( + Layer.provideMerge(repositoryLayer), + Layer.provide(sessions(() => Effect.succeed(Option.some(value.session)))), + ), + ), + ); + assert.strictEqual(result.code, "unsupported_authority"); + }), +); + +it.effect( + "rechecks the native session, issuer, scoped grant and qualified binding at every stage", + () => + Effect.gen(function* () { + const value = yield* fixture; + let currentSession: AuthSessions.AuthSessionRecord | null = value.session; + let currentGrant = value.grant; + let currentBinding = value.historical; + let enrolledSessionId = actorSessionId; + let issuer = "fixture-issuer"; + let reads = 0; + const authorityLayer = Authority.NativeCreationAuthorityLive.pipe( + Layer.provideMerge(repositoryLayer), + Layer.provide( + sessions(() => + Effect.sync(() => { + reads++; + return Option.fromNullishOr(currentSession); + }), + ), + ), + Layer.provide( + Layer.succeed(Authority.NativeCreationGrantResolver, { + resolveCurrent: () => + Effect.sync(() => ({ + enrolledSessionId, + trustedIssuerId: issuer, + grant: currentGrant, + })), + }), + ), + Layer.provide( + Layer.succeed(Authority.NativeCreationBindingResolver, { + resolveCurrent: () => Effect.sync(() => currentBinding), + }), + ), + ); + yield* Effect.gen(function* () { + const authority = yield* Authority.NativeCreationAuthority; + const sql = yield* SqlClient.SqlClient; + const authorize = ( + stage: Authority.NativeCreationStage = "claim", + recoveryScopeId?: string, + recoveryResource?: Authority.NativeCreationGrant["recoveryScopes"][number]["resource"], + ) => + authority.authorize({ + actorSessionId, + guard, + preparation: value.preparation, + resources: value.resources, + stage, + ...(recoveryScopeId === undefined ? {} : { recoveryScopeId }), + ...(recoveryResource === undefined ? {} : { recoveryResource }), + }); + assert.isFalse(yield* authority.isAutomationEnrolled(actorSessionId)); + assert.strictEqual((yield* authorize().pipe(Effect.flip)).code, "stale_grant"); + assert.strictEqual(reads, 0); + yield* sql`INSERT INTO native_creation_automation_enrollments (session_id, enrolled_at) + VALUES (${actorSessionId}, '2026-10-02T12:00:00Z')`; + assert.deepEqual(yield* authorize(), value.historical); + assert.deepEqual(yield* authorize("fetch"), value.historical); + assert.strictEqual(reads, 2); + for (const session of [ + null, + { ...value.session, scopes: [] }, + { ...value.session, revokedAt: DateTime.makeUnsafe("2026-01-02T00:00:00Z") }, + { ...value.session, expiresAt: DateTime.makeUnsafe("2020-01-01T00:00:00Z") }, + ]) { + currentSession = session; + assert.strictEqual((yield* authorize("fetch").pipe(Effect.flip)).code, "stale_grant"); + } + currentSession = value.session; + for (const changed of [ + { revoked: true }, + { revision: 2 }, + { actorSessionId: AuthSessionId.make("other-session") }, + { issuerId: "untrusted" }, + { expiresAt: DateTime.makeUnsafe("2020-01-01T00:00:00Z") }, + ]) { + currentGrant = { ...value.grant, ...changed }; + assert.strictEqual((yield* authorize().pipe(Effect.flip)).code, "stale_grant"); + assert.isTrue(yield* authority.isAutomationEnrolled(actorSessionId)); + } + currentGrant = value.grant; + enrolledSessionId = AuthSessionId.make("other-session"); + assert.strictEqual((yield* authorize().pipe(Effect.flip)).code, "stale_grant"); + enrolledSessionId = actorSessionId; + issuer = ""; + assert.strictEqual((yield* authorize().pipe(Effect.flip)).code, "stale_grant"); + issuer = "fixture-issuer"; + for (const changed of [ + { preparationSha256: "0".repeat(64) }, + { operationId: "other-operation" }, + { resources: { ...value.resources, worktreePath: "/other/worktree" } }, + { allowedStages: [] }, + ]) { + currentGrant = { ...value.grant, ...changed }; + assert.strictEqual((yield* authorize().pipe(Effect.flip)).code, "binding_mismatch"); + } + currentGrant = value.grant; + for (const changed of [ + { environmentId: "other-environment" }, + { projectCwd: "/other/project" }, + { accountBindingRevision: 2 }, + { accountBindingId: "unqualified-account" }, + { accountRef: "other-account" }, + { + providerModelSelection: { + ...value.historical.providerModelSelection, + model: "other-model", + }, + }, + ]) { + currentBinding = { ...value.historical, ...changed }; + assert.strictEqual((yield* authorize().pipe(Effect.flip)).code, "binding_mismatch"); + } + currentBinding = value.historical; + assert.strictEqual( + (yield* authorize("cleanup").pipe(Effect.flip)).code, + "binding_mismatch", + ); + assert.strictEqual( + (yield* authorize("cleanup", "other-recovery").pipe(Effect.flip)).code, + "binding_mismatch", + ); + const resource = value.grant.recoveryScopes[0]!.resource; + assert.strictEqual( + (yield* authorize("cleanup", "fixture-recovery").pipe(Effect.flip)).code, + "binding_mismatch", + ); + if (resource.kind !== "thread") + return yield* Effect.die("Fixture cleanup resource changed"); + assert.strictEqual( + (yield* authorize("cleanup", "fixture-recovery", { + ...resource, + incarnation: { eventId: EventId.make("other-created-event"), sequence: 1 }, + }).pipe(Effect.flip)).code, + "binding_mismatch", + ); + assert.deepEqual( + yield* authorize("cleanup", "fixture-recovery", resource), + value.historical, + ); + assert.isTrue(yield* authority.isAutomationEnrolled(actorSessionId)); + }).pipe(Effect.provide(authorityLayer)); + }), +); + +it.effect("unknown native enrollment lookup denies through the authority port", () => + Effect.gen(function* () { + const value = yield* fixture; + yield* Effect.gen(function* () { + const authority = yield* Authority.NativeCreationAuthority; + assert.strictEqual( + (yield* authority.isAutomationEnrolled(actorSessionId).pipe(Effect.flip)).code, + "unsupported_authority", + ); + assert.strictEqual( + (yield* authority + .authorize({ + actorSessionId, + guard, + preparation: value.preparation, + resources: value.resources, + stage: "claim", + }) + .pipe(Effect.flip)).code, + "unsupported_authority", + ); + }).pipe( + Effect.provide( + Authority.NativeCreationAuthorityUnavailable.pipe( + Layer.provide(repository.pipe(Layer.provide(memory))), + Layer.provide(sessions(() => Effect.succeed(Option.some(value.session)))), + ), + ), + ); + }), +); diff --git a/apps/server/src/nativeCreation/NativeCreationAuthority.ts b/apps/server/src/nativeCreation/NativeCreationAuthority.ts new file mode 100644 index 000000000..1745c55fb --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationAuthority.ts @@ -0,0 +1,254 @@ +import { + type AuthSessionId, + type NativeCreationGuard, + type NativeCreationEffect, + NativeCreationHistoricalBinding, + NativeCreationRejectionCode, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; +import * as Repository from "./NativeCreationRepository.ts"; +import { + nativeCreationCanonicalJson, + type ValidatedNativeCreationPreparation, +} from "./NativeCreationPreparation.ts"; + +export class NativeCreationAuthorityError extends Schema.TaggedError()( + "NativeCreationAuthorityError", + { code: NativeCreationRejectionCode, message: Schema.String }, +) {} +const decodeHistoricalBinding = Schema.decodeUnknownEffect(NativeCreationHistoricalBinding); + +export interface NativeCreationResources { + readonly projectCwd: string; + readonly branch: string; + readonly worktreePath: string; +} + +export type NativeCreationStage = + | "claim" + | "normalization" + | "tracker_registration" + | "bootstrap_detachment" + | "fetch" + | "worktree" + | "worktree_ownership" + | "native_command" + | "setup" + | "setup_detachment" + | "setup_completion_detachment" + | "cleanup" + | "deletion_drain" + | "git_status_refresh"; + +export interface NativeCreationAuthorityInput { + readonly actorSessionId: AuthSessionId; + readonly preparation: ValidatedNativeCreationPreparation; + readonly guard: NativeCreationGuard; + readonly resources: NativeCreationResources; + readonly stage: NativeCreationStage; + readonly recoveryScopeId?: string; + readonly recoveryResource?: Extract["resource"]; +} + +export interface NativeCreationGrant { + readonly grantId: string; + readonly revision: number; + readonly actorSessionId: AuthSessionId; + readonly issuerId: string; + readonly expiresAt: DateTime.Utc; + readonly revoked: boolean; + readonly operationId: string; + readonly preparationId: string; + readonly preparationSha256: string; + readonly bindingDigest: string; + readonly binding: NativeCreationHistoricalBinding; + readonly resources: NativeCreationResources; + readonly allowedStages: ReadonlyArray; + readonly recoveryScopes: ReadonlyArray<{ + readonly scopeId: string; + readonly resource: Extract["resource"]; + }>; +} + +export class NativeCreationGrantResolver extends Context.Service< + NativeCreationGrantResolver, + { + readonly resolveCurrent: (input: { + readonly actorSessionId: AuthSessionId; + readonly guard: NativeCreationGuard; + }) => Effect.Effect< + { + readonly enrolledSessionId: AuthSessionId; + readonly trustedIssuerId: string; + readonly grant: NativeCreationGrant; + }, + NativeCreationAuthorityError + >; + } +>()("t3/orchestration/NativeCreationAuthority/NativeCreationGrantResolver") {} + +// This port must read current native environment, project, enabled provider and qualified account mapping. +export class NativeCreationBindingResolver extends Context.Service< + NativeCreationBindingResolver, + { + readonly resolveCurrent: ( + preparation: ValidatedNativeCreationPreparation, + ) => Effect.Effect; + } +>()("t3/orchestration/NativeCreationAuthority/NativeCreationBindingResolver") {} + +const unavailable = () => + new NativeCreationAuthorityError({ + code: "unsupported_authority", + message: "Native creation authority has not been qualified", + }); +export const NativeCreationGrantResolverUnavailable = Layer.succeed(NativeCreationGrantResolver, { + resolveCurrent: () => Effect.fail(unavailable()), +}); +export const NativeCreationBindingResolverUnavailable = Layer.succeed( + NativeCreationBindingResolver, + { + resolveCurrent: () => Effect.fail(unavailable()), + }, +); + +export class NativeCreationAuthority extends Context.Service< + NativeCreationAuthority, + { + readonly authorize: ( + input: NativeCreationAuthorityInput, + ) => Effect.Effect; + readonly isAutomationEnrolled: ( + actorSessionId: AuthSessionId, + ) => Effect.Effect; + } +>()("t3/orchestration/NativeCreationAuthority") {} + +const makeNativeCreationAuthority = Effect.gen(function* () { + const sessions = yield* AuthSessions.AuthSessionRepository; + const grants = yield* NativeCreationGrantResolver; + const bindings = yield* NativeCreationBindingResolver; + const repository = yield* Repository.NativeCreationRepository; + const isAutomationEnrolled = (actorSessionId: AuthSessionId) => + repository.hasAutomationEnrollment(actorSessionId).pipe(Effect.mapError(() => unavailable())); + + const authorize = Effect.fn("NativeCreationAuthority.authorize")(function* ( + input: NativeCreationAuthorityInput, + ) { + const now = yield* DateTime.now; + if (!(yield* isAutomationEnrolled(input.actorSessionId))) { + return yield* new NativeCreationAuthorityError({ + code: "stale_grant", + message: "Native session has no permanent automation enrollment", + }); + } + const session = yield* sessions + .getById({ sessionId: input.actorSessionId }) + .pipe(Effect.mapError(() => unavailable())); + if ( + Option.isNone(session) || + session.value.revokedAt !== null || + DateTime.toEpochMillis(session.value.expiresAt) <= DateTime.toEpochMillis(now) || + !session.value.scopes.includes("orchestration:operate") + ) { + return yield* new NativeCreationAuthorityError({ + code: "stale_grant", + message: "Current native session cannot operate orchestration", + }); + } + const resolved = yield* grants.resolveCurrent({ + actorSessionId: input.actorSessionId, + guard: input.guard, + }); + const grant = resolved.grant; + if ( + resolved.enrolledSessionId !== input.actorSessionId || + grant.actorSessionId !== input.actorSessionId || + !resolved.trustedIssuerId || + grant.issuerId !== resolved.trustedIssuerId || + grant.revoked || + grant.grantId !== input.guard.grantId || + grant.revision !== input.guard.grantRevision || + DateTime.toEpochMillis(grant.expiresAt) <= DateTime.toEpochMillis(now) + ) { + return yield* new NativeCreationAuthorityError({ + code: "stale_grant", + message: "Native creation enrollment or grant is stale", + }); + } + const preparation = input.preparation; + const currentBinding = yield* bindings.resolveCurrent(preparation); + const binding = yield* decodeHistoricalBinding(currentBinding).pipe( + Effect.mapError( + () => + new NativeCreationAuthorityError({ + code: "binding_mismatch", + message: "Current qualified native binding is invalid", + }), + ), + ); + const expected = { + backendInstance: preparation.binding.backend_instance, + environmentId: preparation.binding.environment_id, + projectId: preparation.binding.project_id, + projectCwd: preparation.binding.project_cwd, + accountRef: preparation.binding.account_ref, + accountBindingId: binding.accountBindingId, + accountBindingRevision: binding.accountBindingRevision, + providerModelSelection: preparation.binding.provider_model_selection, + runtimeMode: preparation.binding.runtime_mode, + interactionMode: preparation.binding.interaction_mode, + baseBranch: preparation.binding.base_branch, + startFromOrigin: preparation.binding.start_from_origin, + runSetupScript: preparation.binding.run_setup_script, + requestedBranch: preparation.command.bootstrap.prepareWorktree.branch, + }; + if ( + grant.operationId !== preparation.operationId || + grant.preparationId !== preparation.preparationId || + grant.preparationSha256 !== preparation.preparationSha256 || + grant.bindingDigest !== preparation.bindingDigest || + nativeCreationCanonicalJson(binding) !== nativeCreationCanonicalJson(expected) || + nativeCreationCanonicalJson(grant.binding) !== nativeCreationCanonicalJson(binding) || + nativeCreationCanonicalJson(grant.resources) !== + nativeCreationCanonicalJson(input.resources) || + input.resources.projectCwd !== binding.projectCwd || + input.resources.branch !== binding.requestedBranch || + !input.resources.worktreePath.startsWith("/") || + !grant.allowedStages.includes(input.stage) || + (input.stage === "cleanup" && + !grant.recoveryScopes.some( + (scope) => + scope.scopeId === input.recoveryScopeId && + input.recoveryResource !== undefined && + nativeCreationCanonicalJson(scope.resource) === + nativeCreationCanonicalJson(input.recoveryResource), + )) + ) { + return yield* new NativeCreationAuthorityError({ + code: "binding_mismatch", + message: "Native creation intent, binding or resource scope disagrees", + }); + } + return binding; + }); + return NativeCreationAuthority.of({ + authorize, + isAutomationEnrolled, + }); +}); + +export const NativeCreationAuthorityLive = Layer.effect( + NativeCreationAuthority, + makeNativeCreationAuthority, +); +export const NativeCreationAuthorityUnavailable = NativeCreationAuthorityLive.pipe( + Layer.provide(NativeCreationGrantResolverUnavailable), + Layer.provide(NativeCreationBindingResolverUnavailable), +); diff --git a/apps/server/src/nativeCreation/NativeCreationPreparation.test.ts b/apps/server/src/nativeCreation/NativeCreationPreparation.test.ts new file mode 100644 index 000000000..c9c63e935 --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationPreparation.test.ts @@ -0,0 +1,192 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { + decodeNativeBootstrapSubmission, + nativeCreationCanonicalJson, + nativeCreationSha256, + nativePreparationCommand, + validateNativeCreationPreparation, +} from "./NativeCreationPreparation.ts"; + +// Actual public synthetic producer vectors: Voice e35a1974, t3_bootstrap.py prepare_bootstrap. +const pythonVectors = [ + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model"},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7d9e191731bb4bd988355a45cb3baaf494678f58cc5ef3bd831ebac3340285f9","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-10-02T12:34:56Z","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model"},"projectId":"fixture-project","runtimeMode":"full-access","title":"Synthetic thread","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-aa4c25ff545a65b500bd7830","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-aa4c25ff545a65b500bd783049ea3587e2e7e1e9f2df8b104493d0d95eb20af0","createdAt":"2026-10-02T12:34:56Z","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-aa4c25ff545a65b500bd783049ea3587e2e7e1e9f2df8b104493d0d95eb20af0","role":"user","text":"Create a test thread"},"runtimeMode":"full-access","threadId":"voice-thread-aa4c25ff545a65b500bd783049ea3587e2e7e1e9f2df8b104493d0d95eb20af0","type":"thread.turn.start"},"command_digest":"1b0f82e7cfe4a3ef0e23c846464b329039a8b85229b4a712b80d63ce85136998","operation_id":"fixture-basic","preparation_id":"voice-bootstrap-aa4c25ff545a65b500bd783049ea3587e2e7e1e9f2df8b104493d0d95eb20af0","prompt_digest":"0cec0521300157dba847e178f58c8e7a1167e09fac7228c2d75888f5879f0569","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "e9c979706cee3d947b5373bb4ea16d1ea61bb49f83f1f12b6453e4fb74b61622", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model","options":[{"id":"z","value":true},{"id":"a","value":"high"}]},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"818e2389c69d6cb541faee236eda09393a669741fc590e18de03c89f2e3fb885","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-10-02T08:34:56.123456-04:00","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model","options":[{"id":"z","value":true},{"id":"a","value":"high"}]},"projectId":"fixture-project","runtimeMode":"full-access","title":"Unicode 雪","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-ed9ca90d0042c1268996a855","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-ed9ca90d0042c1268996a8551a5b5029b1a6c27e60c1def1bce176e849c5d11b","createdAt":"2026-10-02T08:34:56.123456-04:00","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-ed9ca90d0042c1268996a8551a5b5029b1a6c27e60c1def1bce176e849c5d11b","role":"user","text":"雪 🧪 é\\n\\t\\b\\f\\r\\u0000\\u001f
"},"runtimeMode":"full-access","threadId":"voice-thread-ed9ca90d0042c1268996a8551a5b5029b1a6c27e60c1def1bce176e849c5d11b","type":"thread.turn.start"},"command_digest":"f1204c723d5f98d3fd47e10a339b0a5fa845cd6e95036fc6332793ae9b955ed2","operation_id":"fixture-unicode","preparation_id":"voice-bootstrap-ed9ca90d0042c1268996a8551a5b5029b1a6c27e60c1def1bce176e849c5d11b","prompt_digest":"aa122aed1fb570b8eb22b9f6215fb3eb776050f196fc70086df9908f7b7e183c","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "fd1334f7c9bb430072d78700d67e63c2f4e15dfed0e55052f6282d0b772ea8a0", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model","options":[{"id":"a","value":"high"},{"id":"z","value":false}]},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7e90236e66b1dbe0f1a1cd47407603105e40c960cf03b7c1258e9a6074fb999a","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-10-02T12:34:56+00:00","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model","options":[{"id":"a","value":"high"},{"id":"z","value":false}]},"projectId":"fixture-project","runtimeMode":"full-access","title":"Sorted object keys","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-449c049ec4eec031daba9c09","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-449c049ec4eec031daba9c0948a3ef09d734f03519e7e88c8d3c788ab39d9089","createdAt":"2026-10-02T12:34:56+00:00","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-449c049ec4eec031daba9c0948a3ef09d734f03519e7e88c8d3c788ab39d9089","role":"user","text":"Options with reverse IDs"},"runtimeMode":"full-access","threadId":"voice-thread-449c049ec4eec031daba9c0948a3ef09d734f03519e7e88c8d3c788ab39d9089","type":"thread.turn.start"},"command_digest":"de42b4bf7f8a1ccda235f68d410285210d65809721aaa383896fc8c93fcc38fa","operation_id":"fixture-options","preparation_id":"voice-bootstrap-449c049ec4eec031daba9c0948a3ef09d734f03519e7e88c8d3c788ab39d9089","prompt_digest":"09c65720131afabb879f5e190b4e5546012c4ffe5d549a552f52376c04683d0d","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "1c715e5c0f11e4390d2200e04a2f02598e3cde787396e90b79a0b9f0e30f838c", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model"},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7d9e191731bb4bd988355a45cb3baaf494678f58cc5ef3bd831ebac3340285f9","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"20261002T123456Z","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model"},"projectId":"fixture-project","runtimeMode":"full-access","title":"Basic timestamp","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-f4993fe3682795c161932b71","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-f4993fe3682795c161932b71d42efd03b223fbbf21eb4ed3fa06253667245cb2","createdAt":"20261002T123456Z","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-f4993fe3682795c161932b71d42efd03b223fbbf21eb4ed3fa06253667245cb2","role":"user","text":"Basic time"},"runtimeMode":"full-access","threadId":"voice-thread-f4993fe3682795c161932b71d42efd03b223fbbf21eb4ed3fa06253667245cb2","type":"thread.turn.start"},"command_digest":"c12bd874f9d5d3c5b96eb80b3a4991470969e62e48b8adaae5ca4ec5a0db94cf","operation_id":"fixture-basic-time","preparation_id":"voice-bootstrap-f4993fe3682795c161932b71d42efd03b223fbbf21eb4ed3fa06253667245cb2","prompt_digest":"fb67be97ef4e7258ddf2c39267a42174611b59475efaa647d880d20333e07543","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "0200605eb05346b0dbc24ffc416c2d06ba5386d29dadf6daa8b94395782006ac", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model"},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7d9e191731bb4bd988355a45cb3baaf494678f58cc5ef3bd831ebac3340285f9","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-W40-5T12:34:56+00:00","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model"},"projectId":"fixture-project","runtimeMode":"full-access","title":"Week timestamp","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-71cf8b0fc216d903bf9cacfc","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-71cf8b0fc216d903bf9cacfc071918afddddfbcbf130b735096558b3d7cfaaf8","createdAt":"2026-W40-5T12:34:56+00:00","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-71cf8b0fc216d903bf9cacfc071918afddddfbcbf130b735096558b3d7cfaaf8","role":"user","text":"Week time"},"runtimeMode":"full-access","threadId":"voice-thread-71cf8b0fc216d903bf9cacfc071918afddddfbcbf130b735096558b3d7cfaaf8","type":"thread.turn.start"},"command_digest":"0c3e84d9661217b0d203dbd4d92271260439b93615e50c08812d7c7e5f01ab19","operation_id":"fixture-week-time","preparation_id":"voice-bootstrap-71cf8b0fc216d903bf9cacfc071918afddddfbcbf130b735096558b3d7cfaaf8","prompt_digest":"50bea8a6176a5b4b97719e49e8af230e27d2f99bebfffbda19cb5cd5d450c841","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "c6f5e6a7bafa85b373449cf0b6f6ad9a1bcac4a7f877bc4bee60eaa3a4d61a3c", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model"},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7d9e191731bb4bd988355a45cb3baaf494678f58cc5ef3bd831ebac3340285f9","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-10-02T12:34:56+00:00:30.123456","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model"},"projectId":"fixture-project","runtimeMode":"full-access","title":"Offset timestamp","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-0bc8df814f12afbc689cea1e","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-0bc8df814f12afbc689cea1ee5069d237500e8e1fb49c3377a83f3a2e5da8e55","createdAt":"2026-10-02T12:34:56+00:00:30.123456","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-0bc8df814f12afbc689cea1ee5069d237500e8e1fb49c3377a83f3a2e5da8e55","role":"user","text":"Offset seconds"},"runtimeMode":"full-access","threadId":"voice-thread-0bc8df814f12afbc689cea1ee5069d237500e8e1fb49c3377a83f3a2e5da8e55","type":"thread.turn.start"},"command_digest":"569f7a5853737ca0d57e03100278f0f5e28c7d3762e973e4f2d3485c42158ceb","operation_id":"fixture-offset-seconds","preparation_id":"voice-bootstrap-0bc8df814f12afbc689cea1ee5069d237500e8e1fb49c3377a83f3a2e5da8e55","prompt_digest":"b9878d65eae625d2d8b9eeebe78989d409f902267b3fdc775b50ef54e36a11de","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "7e10733900dac8ff24a25107abd89388f967b0f9464d26dd80688f018ebbc69c", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model"},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7d9e191731bb4bd988355a45cb3baaf494678f58cc5ef3bd831ebac3340285f9","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-10-02T12:34:56,123456+02","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model"},"projectId":"fixture-project","runtimeMode":"full-access","title":"Comma timestamp","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-e17fa5c9bb7169b6b33e59b0","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-e17fa5c9bb7169b6b33e59b0b86491e81bf998b82cdca851eb62205f0a7a17ae","createdAt":"2026-10-02T12:34:56,123456+02","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-e17fa5c9bb7169b6b33e59b0b86491e81bf998b82cdca851eb62205f0a7a17ae","role":"user","text":"Comma fraction"},"runtimeMode":"full-access","threadId":"voice-thread-e17fa5c9bb7169b6b33e59b0b86491e81bf998b82cdca851eb62205f0a7a17ae","type":"thread.turn.start"},"command_digest":"bf84eebafafcf50b8ce70609fa5ea6b129a195441c3e47d106d0560b1759e442","operation_id":"fixture-comma-fraction","preparation_id":"voice-bootstrap-e17fa5c9bb7169b6b33e59b0b86491e81bf998b82cdca851eb62205f0a7a17ae","prompt_digest":"1202a37974b275ef1c79871f1e06447a1f39e59cabb30acbc1aa2eae5ecfdb01","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "a1b7bed97671790457e4d89fe9a5dc7879a223f33c70e1618f62aadb3b4a1a89", + }, + { + preparation: + '{"binding":{"account_ref":"fixture-account","backend_instance":"fixture-backend","base_branch":"main","environment_id":"fixture-environment","interaction_mode":"default","project_cwd":"/fixture/project","project_id":"fixture-project","provider_model_selection":{"instanceId":"codex","model":"fixture-model"},"run_setup_script":false,"runtime_mode":"full-access","start_from_origin":false},"binding_digest":"7d9e191731bb4bd988355a45cb3baaf494678f58cc5ef3bd831ebac3340285f9","command":{"bootstrap":{"createThread":{"branch":null,"createdAt":"2026-10-02T12:34:56Z","interactionMode":"default","modelSelection":{"instanceId":"codex","model":"fixture-model"},"projectId":"fixture-project","runtimeMode":"full-access","title":"Synthetic title","worktreePath":null},"prepareWorktree":{"baseBranch":"main","branch":"t3code/voice-053a14ad96571aa95e4bb95f","projectCwd":"/fixture/project","requireWorktree":true,"startFromOrigin":false},"runSetupScript":false},"commandId":"voice-command-053a14ad96571aa95e4bb95fd0dc37066e1b533c5ae928c4d5e9716a3f7129ac","createdAt":"2026-10-02T12:34:56Z","interactionMode":"default","message":{"attachments":[],"messageId":"voice-message-053a14ad96571aa95e4bb95fd0dc37066e1b533c5ae928c4d5e9716a3f7129ac","role":"user","text":"  "},"runtimeMode":"full-access","threadId":"voice-thread-053a14ad96571aa95e4bb95fd0dc37066e1b533c5ae928c4d5e9716a3f7129ac","type":"thread.turn.start"},"command_digest":"f1b5260f71c1ced0eefed180ac62d7c91f4d523cf95e697a118ca3fd98d6b087","operation_id":"fixture-python-strip","preparation_id":"voice-bootstrap-053a14ad96571aa95e4bb95fd0dc37066e1b533c5ae928c4d5e9716a3f7129ac","prompt_digest":"6209822005ccdf32080612cf1e33d3727eee0d512c54d59b397027b6c0acf87c","schema":"voice.t3-bootstrap-preparation/v1"}', + sha256: "6167660aa8190550de51cc8f7292c6599558ecc1db297ad7bab114f6573c37d0", + }, +] as const; + +for (const [index, vector] of pythonVectors.entries()) { + it.effect(`matches actual Python canonical vector ${index}`, () => + Effect.gen(function* () { + const preparation = yield* validateNativeCreationPreparation( + new TextEncoder().encode(vector.preparation), + ); + assert.strictEqual(preparation.canonicalText, vector.preparation); + assert.strictEqual(preparation.preparationSha256, vector.sha256); + assert.strictEqual(nativeCreationSha256(preparation.canonicalText), vector.sha256); + }), + ); +} + +it.effect("rejects original-byte, shape, digest, identity and binding disagreement", () => + Effect.gen(function* () { + const source = pythonVectors[0].preparation; + const invalid = [ + ` ${source}`, + source.replace('"binding":{', '"binding":{"extra":false,'), + source.replace( + '"schema":"voice.t3-bootstrap-preparation/v1"', + '"schema":"voice.t3-bootstrap-preparation/v1","schema":"voice.t3-bootstrap-preparation/v1"', + ), + source.replace('"attachments":[]', '"attachments":[{}]'), + source.replace('"requireWorktree":true', '"requireWorktree":false'), + source.replace('"role":"user"', '"role":"assistant"'), + source.replace('"title":"Synthetic thread"', '"title":"Synthetic thread","unknown":true'), + source.replace('"account_ref":"fixture-account"', '"account_ref":"other-account"'), + source.replace('"command_digest":"1', '"command_digest":"2'), + source.replace("voice-command-aa4c", "voice-command-bb4c"), + source.replace("t3code/voice-aa4c", "t3code/voice-bb4c"), + source.replace('"branch":null', '"branch":"main"'), + source.replace(/2026-10-02T12:34:56Z/g, "2026-10-02T12:34:56"), + source.replace('"text":"Create a test thread"', '"text":"\\ud800"'), + source.replace('"instanceId":"codex"', '"instanceId":"invalid.provider"'), + ]; + for (const text of invalid) { + const result = yield* validateNativeCreationPreparation(new TextEncoder().encode(text)).pipe( + Effect.flip, + ); + assert.strictEqual(result.code, "invalid_preparation"); + } + for (const bytes of [ + new Uint8Array([0xc3, 0x28]), + new Uint8Array(1_048_577), + new Uint8Array(), + ]) { + const error = yield* validateNativeCreationPreparation(bytes).pipe(Effect.flip); + assert.strictEqual(error.code, "invalid_preparation"); + } + }), +); + +it.effect("counts Unicode code points and rejects lone surrogates before hashing", () => + Effect.gen(function* () { + const parsed = yield* validateNativeCreationPreparation( + new TextEncoder().encode(pythonVectors[0].preparation), + ); + const binding = parsed.binding; + const prepare = (text: string, timestamp = "2026-10-02T12:34:56Z") => { + const command = nativePreparationCommand( + parsed.operationId, + binding, + text, + "Synthetic thread", + timestamp, + ); + return nativeCreationCanonicalJson({ + schema: "voice.t3-bootstrap-preparation/v1", + preparation_id: parsed.preparationId, + operation_id: parsed.operationId, + binding, + binding_digest: parsed.bindingDigest, + prompt_digest: nativeCreationSha256(text), + command_digest: nativeCreationSha256(nativeCreationCanonicalJson(command)), + command, + }); + }; + const accepted = yield* validateNativeCreationPreparation( + new TextEncoder().encode(prepare("🧪".repeat(100_000))), + ); + assert.strictEqual([...accepted.command.message.text].length, 100_000); + for (const text of ["🧪".repeat(100_001), "\ud800", " ", "\u0085", "\u001c\u001f"]) { + assert.strictEqual( + (yield* validateNativeCreationPreparation(new TextEncoder().encode(prepare(text))).pipe( + Effect.flip, + )).code, + "invalid_preparation", + ); + } + for (const timestamp of [ + "2026-02-30T12:00:00Z", + "2026-13-01T12:00:00Z", + "0000-01-01T12:00:00Z", + "2025-W53-1T12:00:00Z", + "2026-10-02T24:00:00Z", + "2026-10-02T12:00:60Z", + "2026-10-02T12:00:00+24:00", + "2026-10-02T12:00:00", + "2026-10-02Z12:00:00Z", + ]) { + assert.strictEqual( + (yield* validateNativeCreationPreparation( + new TextEncoder().encode(prepare("Synthetic prompt", timestamp)), + ).pipe(Effect.flip)).code, + "invalid_preparation", + ); + } + }), +); + +it.effect("decodes only a bounded guarded canonical base64 submission", () => + Effect.gen(function* () { + const input = { + schema: "t3.native-bootstrap-submission/v1", + preparationBase64: Buffer.from(pythonVectors[0].preparation).toString("base64"), + creationGuard: { + schema: "t3.native-creation-guard/v1", + grantId: "fixture-grant", + grantRevision: 1, + }, + }; + const result = yield* decodeNativeBootstrapSubmission(input); + assert.strictEqual(result.preparation.preparationSha256, pythonVectors[0].sha256); + for (const invalid of [ + { ...input, extra: true }, + { ...input, creationGuard: { ...input.creationGuard, grantRevision: 0 } }, + { ...input, preparationBase64: "YQ==" }, + { ...input, preparationBase64: "Yh==" }, + { ...input, preparationBase64: "YQ" }, + ]) { + assert.strictEqual( + (yield* decodeNativeBootstrapSubmission(invalid).pipe(Effect.flip)).code, + "invalid_preparation", + ); + } + }), +); diff --git a/apps/server/src/nativeCreation/NativeCreationPreparation.ts b/apps/server/src/nativeCreation/NativeCreationPreparation.ts new file mode 100644 index 000000000..1f71e5487 --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationPreparation.ts @@ -0,0 +1,315 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeBuffer from "node:buffer"; +import { + NativeBootstrapSubmission, + NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES, + ProviderInstanceId, + RuntimeMode, + ProviderInteractionMode, + type OrchestrationV2Command, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const validUnicode = (value: string) => + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? + value.replace( + // oxlint-disable-next-line no-control-regex -- Python str.strip includes C0 controls and NEL. + /^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/gu, + "", + ); +const exactString = Schema.String.check( + Schema.makeFilter( + (value) => value.length > 0 && pythonStrip(value) === value && validUnicode(value), + ), +); + +function validPreparationTimestamp(value: string): boolean { + const timestamp = + /^(\d{4}-\d{2}-\d{2}|\d{8}|\d{4}-W\d{2}(?:-\d)?|\d{4}W\d{2}\d?)[\s\S](.+?)([+-].+)$/u.exec( + value.replaceAll("Z", "+00:00"), + ); + if (timestamp === null) return false; + const date = timestamp[1]!; + const year = Number(date.slice(0, 4)); + if (year < 1 || year > 9999) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const weekDate = /^\d{4}-?W(\d{2})(?:-?(\d))?$/.exec(date); + if (weekDate === null) { + const digits = date.replaceAll("-", ""); + const month = Number(digits.slice(4, 6)); + const day = Number(digits.slice(6, 8)); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (month < 1 || month > 12 || day < 1 || day > days[month - 1]!) return false; + } else { + const previousYear = year - 1; + const daysBeforeYear = + 365 * previousYear + + Math.floor(previousYear / 4) - + Math.floor(previousYear / 100) + + Math.floor(previousYear / 400); + const januaryFirst = (daysBeforeYear + 1) % 7; + const week = Number(weekDate[1]); + const day = Number(weekDate[2] ?? 1); + if ( + week < 1 || + week > (januaryFirst === 4 || (leap && januaryFirst === 3) ? 53 : 52) || + day < 1 || + day > 7 + ) + return false; + const januaryFourth = daysBeforeYear + 4; + const ordinal = januaryFourth - ((januaryFourth + 6) % 7) + (week - 1) * 7 + day - 1; + if ( + ordinal < 1 || + ordinal > 365 * 9999 + Math.floor(9999 / 4) - Math.floor(9999 / 100) + Math.floor(9999 / 400) + ) + return false; + } + const timeComponents = /^(\d{2})(?:(:?)(\d{2})(?:\2(\d{2}))?)?(?:[.,](\d+))?$/; + const time = timeComponents.exec(timestamp[2]!); + if ( + time === null || + Number(time[1]) > 23 || + Number(time[3] ?? 0) > 59 || + Number(time[4] ?? 0) > 59 + ) + return false; + const offset = timeComponents.exec(timestamp[3]!.slice(1)); + if (offset === null) return false; + return ( + Number(offset[1]) * 3600 + + Number(offset[3] ?? 0) * 60 + + Number(offset[4] ?? 0) + + Number(`0.${(offset[5] ?? "0").slice(0, 6)}`) < + 86400 + ); +} +const modelSelection = Schema.Struct({ + instanceId: ProviderInstanceId, + model: exactString, + options: Schema.optionalKey( + Schema.Array( + Schema.Struct({ id: exactString, value: Schema.Union([exactString, Schema.Boolean]) }), + ), + ), +}); +export const NativePreparationBinding = Schema.Struct({ + backend_instance: exactString, + environment_id: exactString, + project_id: exactString, + project_cwd: exactString.check(Schema.isPattern(/^\//)), + account_ref: exactString, + runtime_mode: RuntimeMode, + interaction_mode: ProviderInteractionMode, + base_branch: exactString, + start_from_origin: Schema.Boolean, + run_setup_script: Schema.Boolean, + provider_model_selection: modelSelection, +}); +export type NativePreparationBinding = typeof NativePreparationBinding.Type; + +const seed = Schema.Struct({ + schema: Schema.Literal("voice.t3-bootstrap-preparation/v1"), + preparation_id: exactString, + operation_id: exactString, + binding: NativePreparationBinding, + binding_digest: exactString, + prompt_digest: exactString, + command_digest: exactString, + command: Schema.Struct({ + message: Schema.Struct({ + text: Schema.String.check( + Schema.makeFilter( + (value) => + pythonStrip(value).length > 0 && [...value].length <= 100_000 && validUnicode(value), + ), + ), + }), + createdAt: exactString, + bootstrap: Schema.Struct({ createThread: Schema.Struct({ title: exactString }) }), + }), +}); +const decodeSeed = Schema.decodeUnknownSync(seed); +const decodeBinding = Schema.decodeUnknownSync(NativePreparationBinding); +const decodeSubmission = Schema.decodeUnknownEffect(NativeBootstrapSubmission); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +export class NativeCreationPreparationError extends Schema.TaggedError()( + "NativeCreationPreparationError", + { code: Schema.Literal("invalid_preparation"), message: Schema.String }, +) {} + +export function nativeCreationCanonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, child: unknown) => { + if (child !== null && typeof child === "object" && !Array.isArray(child)) { + return Object.fromEntries( + Object.entries(child).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), + ); + } + return child; + }); +} + +export const nativeCreationSha256 = (value: string | Uint8Array): string => + NodeCrypto.createHash("sha256").update(value).digest("hex"); + +// This regenerates historical producer bytes; execution requires the separate V2 launch adapter. +export function nativePreparationCommand( + operationId: string, + binding: NativePreparationBinding, + text: string, + title: string, + createdAt: string, +) { + const identity = nativeCreationSha256( + nativeCreationCanonicalJson({ + schema: "voice.t3-bootstrap-identity/v1", + environment_id: binding.environment_id, + project_id: binding.project_id, + backend_instance: binding.backend_instance, + operation_id: operationId, + }), + ); + return { + type: "thread.turn.start" as const, + commandId: `voice-command-${identity}`, + threadId: `voice-thread-${identity}`, + message: { + messageId: `voice-message-${identity}`, + role: "user" as const, + text, + attachments: [], + }, + runtimeMode: binding.runtime_mode, + interactionMode: binding.interaction_mode, + createdAt, + bootstrap: { + createThread: { + projectId: binding.project_id, + title, + modelSelection: binding.provider_model_selection, + runtimeMode: binding.runtime_mode, + interactionMode: binding.interaction_mode, + branch: null, + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: binding.project_cwd, + baseBranch: binding.base_branch, + branch: `t3code/voice-${identity.slice(0, 24)}`, + startFromOrigin: binding.start_from_origin, + requireWorktree: true as const, + }, + runSetupScript: binding.run_setup_script, + }, + }; +} + +export interface ValidatedNativeCreationPreparation { + readonly canonicalText: string; + readonly preparationSha256: string; + readonly preparationId: string; + readonly operationId: string; + readonly binding: NativePreparationBinding; + readonly bindingDigest: string; + readonly promptDigest: string; + readonly commandDigest: string; + readonly command: ReturnType; +} + +export const validateNativeCreationPreparation = Effect.fn("validateNativeCreationPreparation")( + function* ( + bytes: Uint8Array, + ): Effect.fn.Return { + return yield* Effect.try({ + try: () => { + if (bytes.byteLength === 0 || bytes.byteLength > NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES) { + throw new Error("Preparation size is invalid"); + } + const canonicalText = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + const raw = decodeJson(canonicalText); + if (nativeCreationCanonicalJson(raw) !== canonicalText) + throw new Error("Preparation bytes are not canonical"); + const parsed = decodeSeed(raw); + // Seed decoding reads only producer inputs; complete regeneration closes every original shape. + const binding = decodeBinding(parsed.binding, { + onExcessProperty: "error", + }); + if (!validPreparationTimestamp(parsed.command.createdAt)) { + throw new Error("Preparation timestamp requires a valid timezone"); + } + const command = nativePreparationCommand( + parsed.operation_id, + binding, + parsed.command.message.text, + parsed.command.bootstrap.createThread.title, + parsed.command.createdAt, + ); + const identity = command.commandId.slice("voice-command-".length); + const bindingDigest = nativeCreationSha256(nativeCreationCanonicalJson(binding)); + const promptDigest = nativeCreationSha256(command.message.text); + const commandDigest = nativeCreationSha256(nativeCreationCanonicalJson(command)); + const preparationId = `voice-bootstrap-${identity}`; + const expected = { + schema: "voice.t3-bootstrap-preparation/v1", + preparation_id: preparationId, + operation_id: parsed.operation_id, + binding, + binding_digest: bindingDigest, + prompt_digest: promptDigest, + command_digest: commandDigest, + command, + }; + if (nativeCreationCanonicalJson(expected) !== canonicalText) + throw new Error("Complete preparation disagrees with producer inputs"); + return { + canonicalText, + preparationSha256: nativeCreationSha256(bytes), + preparationId, + operationId: parsed.operation_id, + binding, + bindingDigest, + promptDigest, + commandDigest, + command, + }; + }, + catch: () => + new NativeCreationPreparationError({ + code: "invalid_preparation", + message: "Native creation preparation is invalid or noncanonical", + }), + }); + }, +); + +export const decodeNativeBootstrapSubmission = Effect.fn("decodeNativeBootstrapSubmission")( + function* (input: unknown) { + const submission = yield* decodeSubmission(input).pipe( + Effect.mapError( + () => + new NativeCreationPreparationError({ + code: "invalid_preparation", + message: "Native bootstrap submission is invalid", + }), + ), + ); + const bytes = NodeBuffer.Buffer.from(submission.preparationBase64, "base64"); + if (bytes.toString("base64") !== submission.preparationBase64) { + return yield* new NativeCreationPreparationError({ + code: "invalid_preparation", + message: "Preparation base64 is not canonical", + }); + } + const preparation = yield* validateNativeCreationPreparation(bytes); + return { preparation, guard: submission.creationGuard }; + }, +); + +export const nativeCreationCommandDigest = (command: OrchestrationV2Command): string => + nativeCreationSha256(nativeCreationCanonicalJson(command)); diff --git a/apps/server/src/nativeCreation/NativeCreationRepository.test.ts b/apps/server/src/nativeCreation/NativeCreationRepository.test.ts new file mode 100644 index 000000000..3320e0c20 --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationRepository.test.ts @@ -0,0 +1,570 @@ +import { assert, it } from "@effect/vitest"; +import { + NativeCreationHistoricalBinding, + OrchestrationV2Command, + ThreadId, + CommandId, + AuthSessionId, +} from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Authority from "./NativeCreationAuthority.ts"; +import * as Repository from "./NativeCreationRepository.ts"; +import { + NativePreparationBinding, + nativeCreationCanonicalJson, + nativeCreationSha256, + nativePreparationCommand, + validateNativeCreationPreparation, +} from "./NativeCreationPreparation.ts"; +import migration from "../persistence/Migrations/003_JonesNativeCreationIntents.ts"; + +import * as RepositorySqlite from "./NativeCreationRepositorySqlite.ts"; + +const memory = NodeSqliteClient.layer({ filename: ":memory:" }); +const database = Layer.effectDiscard(migration).pipe(Layer.provideMerge(memory)); +const repositoryLayer = RepositorySqlite.layer.pipe(Layer.provideMerge(database)); +const timestamp = "2026-10-02T12:34:56Z"; +const decodeFixtureBinding = Schema.decodeUnknownSync(NativePreparationBinding); +const decodeFixtureHistory = Schema.decodeUnknownSync(NativeCreationHistoricalBinding); +const decodeFixtureCommand = Schema.decodeUnknownSync(OrchestrationV2Command); +const enrolledSessionId = AuthSessionId.make("fixture-enrolled-session"); +const fixture = Effect.fnUntraced(function* ( + operationId = "fixture-operation", + text = "Synthetic prompt", + path = "/fixture/worktree", +) { + const binding = decodeFixtureBinding({ + backend_instance: "fixture-backend", + environment_id: "fixture-environment", + project_id: "fixture-project", + project_cwd: "/fixture/project", + account_ref: "fixture-account", + runtime_mode: "full-access" as const, + interaction_mode: "default" as const, + base_branch: "main", + start_from_origin: false, + run_setup_script: false, + provider_model_selection: { instanceId: "codex", model: "fixture-model" }, + }); + const command = nativePreparationCommand( + operationId, + binding, + text, + "Synthetic thread", + timestamp, + ); + const preparation = yield* validateNativeCreationPreparation( + new TextEncoder().encode( + nativeCreationCanonicalJson({ + schema: "voice.t3-bootstrap-preparation/v1", + operation_id: operationId, + binding, + command, + preparation_id: command.commandId.replace("voice-command-", "voice-bootstrap-"), + binding_digest: nativeCreationSha256(nativeCreationCanonicalJson(binding)), + prompt_digest: nativeCreationSha256(text), + command_digest: nativeCreationSha256(nativeCreationCanonicalJson(command)), + }), + ), + ); + const historical = decodeFixtureHistory({ + backendInstance: binding.backend_instance, + environmentId: binding.environment_id, + projectId: binding.project_id, + projectCwd: binding.project_cwd, + accountRef: binding.account_ref, + accountBindingId: "qualified-fixture-account", + accountBindingRevision: 1, + providerModelSelection: binding.provider_model_selection, + runtimeMode: binding.runtime_mode, + interactionMode: binding.interaction_mode, + baseBranch: binding.base_branch, + startFromOrigin: false, + runSetupScript: false, + requestedBranch: command.bootstrap.prepareWorktree.branch, + }); + const input: Repository.NativeCreationClaimInput = { + preparation, + resources: { + projectCwd: binding.project_cwd, + branch: historical.requestedBranch, + worktreePath: path, + }, + claimId: `claim-${operationId}`, + claimedBootId: "fixture-boot", + claimedAt: timestamp, + actorSessionId: "fixture-session", + grantId: "fixture-grant", + grantRevision: 1, + }; + return { input, historical, preparation, authorize: Effect.succeed(historical) }; +}); + +it.effect( + "commits one invocation claim under concurrent identical submissions and never replaces it", + () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const value = yield* fixture(); + const results = yield* Effect.all( + Array.from({ length: 8 }, (_, index) => + repository.claim({ ...value.input, claimId: `request-${index}` }, value.authorize), + ), + { concurrency: "unbounded" }, + ); + assert.strictEqual(results.filter((result) => result.status === "claimed").length, 1); + assert.strictEqual(results.filter((result) => result.status === "duplicate").length, 7); + assert.strictEqual(new Set(results.map((result) => result.history.intent.claimId)).size, 1); + assert.isNull(results[0]!.history.normalizedCommandDigest); + const old = results[0]!.history.intent; + const duplicate = yield* repository.claim( + { + ...value.input, + claimedBootId: "later-boot", + claimedAt: "2099-01-01T00:00:00Z", + grantRevision: 2, + }, + value.authorize, + ); + assert.deepEqual(duplicate.history.intent, old); + assert.strictEqual(duplicate.status, "duplicate"); + const observed = yield* repository.readHistory(value.preparation.command.commandId); + assert.isTrue(Option.isSome(observed)); + assert.deepEqual(Option.getOrThrow(observed).intent, old); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("reads exact permanent enrollment membership and rejects malformed present markers", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const sql = yield* SqlClient.SqlClient; + assert.isFalse(yield* repository.hasAutomationEnrollment(enrolledSessionId)); + yield* sql`INSERT INTO native_creation_automation_enrollments (session_id, enrolled_at) + VALUES (${enrolledSessionId}, ${timestamp})`; + assert.isTrue(yield* repository.hasAutomationEnrollment(enrolledSessionId)); + assert.isFalse( + yield* repository.hasAutomationEnrollment(AuthSessionId.make("other-native-session")), + ); + yield* sql`INSERT INTO native_creation_automation_enrollments (session_id, enrolled_at) + VALUES ('malformed-native-session', 'not-a-timestamp')`; + assert.strictEqual( + (yield* repository + .hasAutomationEnrollment(AuthSessionId.make("malformed-native-session")) + .pipe(Effect.flip)).code, + "unresolved_claim", + ); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("missing native membership table is unknown rather than an absent marker", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + assert.strictEqual( + (yield* repository.hasAutomationEnrollment(enrolledSessionId).pipe(Effect.flip)).code, + "unresolved_claim", + ); + }).pipe(Effect.provide(layer.pipe(Layer.provide(memory)))), +); + +it.effect( + "rejects changed immutable intent, competing path and duplicate identities without partial claims", + () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const sql = yield* SqlClient.SqlClient; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const changed = yield* fixture("fixture-operation", "Changed prompt"); + const otherPath = yield* fixture("other-operation"); + const otherClaimId = yield* fixture( + "third-operation", + "Synthetic prompt", + "/fixture/other-worktree", + ); + const wrongBranch = { + ...value.input, + claimId: "other-claim", + resources: { ...value.input.resources, branch: "other-branch" }, + }; + for (const [input, authorize] of [ + [changed.input, changed.authorize], + [otherPath.input, otherPath.authorize], + [{ ...otherClaimId.input, claimId: value.input.claimId }, otherClaimId.authorize], + [wrongBranch, value.authorize], + ] as const) { + assert.strictEqual( + (yield* repository.claim(input, authorize).pipe(Effect.flip)).code, + "conflict", + ); + } + assert.deepEqual(yield* sql`SELECT COUNT(*) AS count FROM native_creation_intents`, [ + { count: 1 }, + ]); + assert.isTrue(Option.isNone(yield* repository.readHistory("missing-command"))); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("authority denial creates no claim or started effect", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const sql = yield* SqlClient.SqlClient; + const value = yield* fixture(); + const denied = Effect.fail( + new Authority.NativeCreationAuthorityError({ code: "stale_grant", message: "Synthetic revoked grant" }), + ); + assert.strictEqual( + (yield* repository.claim(value.input, denied).pipe(Effect.flip)).code, + "stale_grant", + ); + for (const changed of [ + { backendInstance: "other-backend" }, + { environmentId: "other-environment" }, + { accountRef: "other-account" }, + { runSetupScript: true }, + { + providerModelSelection: { + ...value.historical.providerModelSelection, + model: "other-model", + }, + }, + ]) { + assert.strictEqual( + (yield* repository + .claim(value.input, Effect.succeed({ ...value.historical, ...changed })) + .pipe(Effect.flip)).code, + "conflict", + ); + } + assert.deepEqual(yield* sql`SELECT COUNT(*) AS count FROM native_creation_intents`, [ + { count: 0 }, + ]); + yield* repository.claim(value.input, value.authorize); + const fact = { + kind: "fetch" as const, + phase: "started" as const, + effectId: "fixture-fetch", + timestamp, + projectCwd: value.input.resources.projectCwd, + baseRef: "main", + }; + assert.strictEqual( + (yield* repository.startEffect(value.input.claimId, fact, denied).pipe(Effect.flip)).code, + "stale_grant", + ); + assert.deepEqual(yield* sql`SELECT COUNT(*) AS count FROM native_creation_effect_facts`, [ + { count: 0 }, + ]); + const persisted = yield* repository.startEffect(value.input.claimId, fact, value.authorize); + const history = Option.getOrThrow( + yield* repository.readHistory(value.preparation.command.commandId), + ); + assert.deepEqual(history.effects, [persisted]); + assert.strictEqual(persisted.ordinal, 0); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("keeps original digest separate and reserves immutable native commands", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const command = decodeFixtureCommand({ + type: "message.dispatch", + createdBy: "user", + creationSource: "server", + commandId: value.preparation.command.commandId, + threadId: value.preparation.command.threadId, + messageId: value.preparation.command.message.messageId, + text: value.preparation.command.message.text, + attachments: [], + modelSelection: value.preparation.binding.provider_model_selection, + dispatchMode: { type: "start_immediately" }, + }); + yield* repository.recordNormalizedCommand(value.input.claimId, command); + yield* repository.recordNormalizedCommand(value.input.claimId, command); + const history = Option.getOrThrow( + yield* repository.readHistory(value.preparation.command.commandId), + ); + assert.strictEqual(history.intent.commandDigest, value.preparation.commandDigest); + assert.strictEqual( + history.normalizedCommandDigest, + nativeCreationSha256(nativeCreationCanonicalJson(command)), + ); + assert.notStrictEqual(history.normalizedCommandDigest, history.intent.commandDigest); + const reserved = Option.getOrThrow(yield* repository.getReservedCommand(command.commandId)); + assert.strictEqual(reserved.claimId, value.input.claimId); + assert.strictEqual(reserved.commandDigest, history.normalizedCommandDigest); + if (command.type !== "message.dispatch") + return yield* Effect.die("Fixture command type changed"); + assert.strictEqual( + (yield* repository + .recordNormalizedCommand(value.input.claimId, { + ...command, + text: "Changed", + }) + .pipe(Effect.flip)).code, + "conflict", + ); + const second = yield* fixture("other-operation", "Synthetic prompt", "/fixture/other-worktree"); + yield* repository.claim(second.input, second.authorize); + assert.strictEqual( + (yield* repository + .reserveCommand(second.input.claimId, { + ...command, + threadId: ThreadId.make(second.preparation.command.threadId), + }) + .pipe(Effect.flip)).code, + "conflict", + ); + const future = yield* fixture( + "future-operation", + "Synthetic prompt", + "/fixture/future-worktree", + ); + yield* repository.reserveCommand(value.input.claimId, { + ...command, + commandId: CommandId.make(future.preparation.command.commandId), + }); + assert.strictEqual( + (yield* repository.claim(future.input, future.authorize).pipe(Effect.flip)).code, + "conflict", + ); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("orders append-only typed facts and preserves external gaps and failed results", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const started = { + kind: "fetch" as const, + phase: "started" as const, + effectId: "fixture-fetch", + timestamp, + projectCwd: value.input.resources.projectCwd, + baseRef: "main", + }; + const completed = { ...started, phase: "completed" as const, result: "failed" as const }; + assert.strictEqual( + (yield* repository.completeEffect(value.input.claimId, completed).pipe(Effect.flip)).code, + "conflict", + ); + const persistedStart = yield* repository.startEffect( + value.input.claimId, + started, + value.authorize, + ); + assert.strictEqual( + (yield* repository + .startEffect(value.input.claimId, started, value.authorize) + .pipe(Effect.flip)).code, + "conflict", + ); + assert.strictEqual( + (yield* repository + .completeEffect(value.input.claimId, { ...completed, baseRef: "other" }) + .pipe(Effect.flip)).code, + "conflict", + ); + assert.deepEqual( + Option.getOrThrow(yield* repository.readHistory(value.preparation.command.commandId)).effects, + [persistedStart], + ); + const persistedCompletion = yield* repository.completeEffect(value.input.claimId, completed); + assert.deepEqual( + Option.getOrThrow(yield* repository.readHistory(value.preparation.command.commandId)).effects, + [persistedStart, persistedCompletion], + ); + assert.strictEqual(persistedStart.ordinal, 0); + assert.strictEqual(persistedCompletion.ordinal, 1); + assert.strictEqual( + (yield* repository.completeEffect(value.input.claimId, completed).pipe(Effect.flip)).code, + "conflict", + ); + assert.strictEqual( + (yield* repository + .startEffect( + value.input.claimId, + { ...started, effectId: "next", projectCwd: "/other/project" }, + value.authorize, + ) + .pipe(Effect.flip)).code, + "conflict", + ); + yield* repository.startEffect( + value.input.claimId, + { + kind: "setup", + phase: "started", + effectId: "fixture-setup", + timestamp, + worktreePath: value.input.resources.worktreePath, + terminalId: "known-terminal", + }, + value.authorize, + ); + assert.strictEqual( + (yield* repository + .completeEffect(value.input.claimId, { + kind: "setup", + phase: "completed", + effectId: "fixture-setup", + timestamp, + worktreePath: value.input.resources.worktreePath, + terminalId: "other-terminal", + exitCode: 0, + result: "succeeded", + }) + .pipe(Effect.flip)).code, + "conflict", + ); + yield* repository.completeEffect(value.input.claimId, { + kind: "setup", + phase: "completed", + effectId: "fixture-setup", + timestamp, + worktreePath: value.input.resources.worktreePath, + terminalId: "known-terminal", + exitCode: 0, + result: "succeeded", + }); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("fact completion joins an enclosing engine transaction and rolls back with it", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const sql = yield* SqlClient.SqlClient; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const started = { + kind: "lifecycle" as const, + phase: "started" as const, + effectId: "fixture-normalization", + timestamp, + threadId: ThreadId.make(value.preparation.command.threadId), + action: "normalization" as const, + }; + const persistedStart = yield* repository.startEffect( + value.input.claimId, + started, + value.authorize, + ); + const completed = { ...started, phase: "completed" as const, result: "succeeded" as const }; + yield* sql`CREATE TABLE synthetic_engine_receipts (command_id TEXT PRIMARY KEY)`; + const aborted = yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql`INSERT INTO synthetic_engine_receipts (command_id) VALUES ('synthetic-command')`; + yield* repository.completeEffect(value.input.claimId, completed); + return yield* Effect.fail("synthetic-transaction-abort"); + }), + ) + .pipe(Effect.flip); + assert.strictEqual(aborted, "synthetic-transaction-abort"); + assert.deepEqual(yield* sql`SELECT * FROM synthetic_engine_receipts`, []); + assert.deepEqual( + Option.getOrThrow(yield* repository.readHistory(value.preparation.command.commandId)).effects, + [persistedStart], + ); + yield* repository.completeEffect(value.input.claimId, completed); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("assigns distinct fact ordinals when asynchronous completions arrive concurrently", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const starts = yield* Effect.all( + ["first", "second"].map((effectId) => + repository.startEffect( + value.input.claimId, + { + kind: "lifecycle", + phase: "started", + effectId, + timestamp, + threadId: ThreadId.make(value.preparation.command.threadId), + action: "git_status_refresh", + }, + value.authorize, + ), + ), + { concurrency: "unbounded" }, + ); + const completions = yield* Effect.all( + starts.map((fact) => { + if (fact.kind !== "lifecycle") return Effect.die("Fixture lifecycle fact changed"); + const { ordinal: _ordinal, ...started } = fact; + return repository.completeEffect(value.input.claimId, { + ...started, + phase: "completed", + result: "succeeded", + }); + }), + { concurrency: "unbounded" }, + ); + assert.deepEqual(starts.map((fact) => fact.ordinal).sort(), [0, 1]); + assert.deepEqual(completions.map((fact) => fact.ordinal).sort(), [2, 3]); + assert.deepEqual( + Option.getOrThrow( + yield* repository.readHistory(value.preparation.command.commandId), + ).effects.map((fact) => fact.ordinal), + [0, 1, 2, 3], + ); + }).pipe(Effect.provide(repositoryLayer)), +); + + +it.effect("reads historical reservations without translating their canonical receipt bytes", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const sql = yield* SqlClient.SqlClient; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const command = value.preparation.command; + const canonical = nativeCreationCanonicalJson(command); + const digest = nativeCreationSha256(canonical); + yield* sql`INSERT INTO native_creation_reserved_commands + (command_id, claim_id, thread_id, command_type, command_digest, canonical_command) + VALUES (${command.commandId}, ${value.input.claimId}, ${command.threadId}, + ${command.type}, ${digest}, ${canonical})`; + const reserved = Option.getOrThrow(yield* repository.getReservedCommand(command.commandId)); + assert.strictEqual(reserved.commandType, "thread.turn.start"); + assert.strictEqual(reserved.canonicalCommand, canonical); + assert.strictEqual(reserved.commandDigest, value.preparation.commandDigest); + }).pipe(Effect.provide(repositoryLayer)), +); + +it.effect("fails closed on unimplemented V2 execution facts without appending history", () => + Effect.gen(function* () { + const repository = yield* Repository.NativeCreationRepository; + const value = yield* fixture(); + yield* repository.claim(value.input, value.authorize); + const fact = { + kind: "native_command", + phase: "started", + effectId: "fixture-v2-dispatch", + timestamp, + commandId: value.preparation.command.commandId, + threadId: value.preparation.command.threadId, + commandType: "message.dispatch", + commandDigest: value.preparation.commandDigest, + } as unknown as Parameters[1]; + assert.strictEqual( + (yield* repository.startEffect(value.input.claimId, fact, value.authorize).pipe(Effect.flip)).code, + "conflict", + ); + assert.deepEqual( + Option.getOrThrow(yield* repository.readHistory(value.preparation.command.commandId)).effects, + [], + ); + }).pipe(Effect.provide(repositoryLayer)), +); diff --git a/apps/server/src/nativeCreation/NativeCreationRepository.ts b/apps/server/src/nativeCreation/NativeCreationRepository.ts new file mode 100644 index 000000000..bb9bc5fff --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationRepository.ts @@ -0,0 +1,124 @@ +import { + NativeCreationEffect, + NativeCreationHistoricalBinding, + type OrchestrationV2Command, + type AuthSessionId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + type NativeCreationAuthorityError, + type NativeCreationResources, +} from "./NativeCreationAuthority.ts"; +import { type ValidatedNativeCreationPreparation } from "./NativeCreationPreparation.ts"; + +export class NativeCreationRepositoryError extends Schema.TaggedError()( + "NativeCreationRepositoryError", + { code: Schema.Literals(["conflict", "unresolved_claim"]), message: Schema.String }, +) {} + +export const NativeCreationStoredIntent = Schema.Struct({ + claimId: Schema.String, + claimedBootId: Schema.String, + claimedAt: Schema.String, + actorSessionId: Schema.String, + grantId: Schema.String, + grantRevision: Schema.Int, + preparationId: Schema.String, + operationId: Schema.String, + preparationSha256: Schema.String, + bindingDigest: Schema.String, + promptDigest: Schema.String, + commandDigest: Schema.String, + commandId: Schema.String, + threadId: Schema.String, + messageId: Schema.String, + canonicalPreparation: Schema.String, + binding: NativeCreationHistoricalBinding, + resources: Schema.Struct({ + projectCwd: Schema.String, + branch: Schema.String, + worktreePath: Schema.String, + }), +}); +export type NativeCreationStoredIntent = typeof NativeCreationStoredIntent.Type; + +export interface NativeCreationHistory { + readonly intent: NativeCreationStoredIntent; + readonly normalizedCommandDigest: string | null; + readonly effects: ReadonlyArray; +} + +export interface NativeCreationClaimInput { + readonly preparation: ValidatedNativeCreationPreparation; + readonly resources: NativeCreationResources; + readonly claimId: string; + readonly claimedBootId: string; + readonly claimedAt: string; + readonly actorSessionId: string; + readonly grantId: string; + readonly grantRevision: number; +} + +// Stored historical reservations remain readable; new reservations decode current V2 commands. +export interface NativeCreationReservedCommand { + readonly claimId: string; + readonly commandId: string; + readonly threadId: string; + readonly commandType: + | OrchestrationV2Command["type"] + | Extract["commandType"]; + readonly commandDigest: string; + readonly canonicalCommand: string; +} + +type WithoutOrdinal = Fact extends NativeCreationEffect ? Omit : never; +export type NativeCreationStartedFact = Extract; +export type NativeCreationCompletedFact = Extract; + +export class NativeCreationRepository extends Context.Service< + NativeCreationRepository, + { + readonly hasAutomationEnrollment: ( + actorSessionId: AuthSessionId, + ) => Effect.Effect; + readonly claim: ( + input: NativeCreationClaimInput, + authorize: Effect.Effect, + ) => Effect.Effect< + { + readonly status: "claimed" | "duplicate"; + readonly history: NativeCreationHistory; + }, + NativeCreationRepositoryError | NativeCreationAuthorityError + >; + readonly readHistory: ( + commandId: string, + ) => Effect.Effect, NativeCreationRepositoryError>; + readonly recordNormalizedCommand: ( + claimId: string, + command: OrchestrationV2Command, + ) => Effect.Effect; + readonly reserveCommand: ( + claimId: string, + command: OrchestrationV2Command, + ) => Effect.Effect; + readonly getReservedCommand: ( + commandId: string, + ) => Effect.Effect, NativeCreationRepositoryError>; + readonly startEffect: ( + claimId: string, + fact: WithoutOrdinal, + authorize: Effect.Effect, + ) => Effect.Effect< + NativeCreationStartedFact, + NativeCreationRepositoryError | NativeCreationAuthorityError + >; + readonly completeEffect: ( + claimId: string, + fact: WithoutOrdinal, + ) => Effect.Effect; + } +>()("t3/persistence/Services/NativeCreationRepository") {} diff --git a/apps/server/src/nativeCreation/NativeCreationRepositoryMigration.test.ts b/apps/server/src/nativeCreation/NativeCreationRepositoryMigration.test.ts new file mode 100644 index 000000000..1751588ff --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationRepositoryMigration.test.ts @@ -0,0 +1,145 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { migrationManifest, runMigrations } from "../persistence/Migrations.ts"; +import migrate from "../persistence/Migrations/003_JonesNativeCreationIntents.ts"; + +const memory = NodeSqliteClient.layer({ filename: ":memory:" }); +it.effect("records additive fork migration 3 without changing upstream migration identity", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + assert.deepEqual(yield* runMigrations(), migrationManifest); + assert.deepEqual(yield* runMigrations(), []); + assert.deepEqual( + yield* sql`SELECT migration_id, name FROM jones_sql_migrations ORDER BY migration_id`, + [ + { migration_id: 1, name: "WorktreeOwnershipLeases" }, + { migration_id: 2, name: "ProjectionThreadRuntimeIdentity" }, + { migration_id: 3, name: "NativeCreationIntents" }, + { migration_id: 4, name: "NativeCreationCommandIdentities" }, + { migration_id: 5, name: "WorkstreamsNativeAttempts" }, + { migration_id: 6, name: "WorkstreamsProviderEnrollments" }, + ], + ); + assert.deepEqual( + yield* sql`SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id`, + migrationManifest.map(([migration_id, name]) => ({ migration_id, name })), + ); + const tables = yield* sql<{ + name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'native_creation_%' ORDER BY name`; + assert.deepEqual( + tables.map((row) => row.name), + [ + "native_creation_automation_enrollments", + "native_creation_effect_facts", + "native_creation_intents", + "native_creation_normalized_commands", + "native_creation_reserved_command_identities", + "native_creation_reserved_commands", + ], + ); + }).pipe(Effect.provide(memory)), +); + +it.effect( + "native session enrollment markers survive attempted update, deletion and replacement", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* migrate; + yield* sql`INSERT INTO native_creation_automation_enrollments (session_id, enrolled_at) + VALUES ('fixture-native-session', '2026-10-02T12:00:00Z')`; + for (const mutation of [ + sql`UPDATE native_creation_automation_enrollments SET enrolled_at = '2099-01-01T00:00:00Z' WHERE session_id = 'fixture-native-session'`, + sql`UPDATE native_creation_automation_enrollments SET session_id = 'other-session' WHERE session_id = 'fixture-native-session'`, + sql`DELETE FROM native_creation_automation_enrollments WHERE session_id = 'fixture-native-session'`, + sql`INSERT OR REPLACE INTO native_creation_automation_enrollments (session_id, enrolled_at) VALUES ('fixture-native-session', '2099-01-01T00:00:00Z')`, + ]) { + assert.isTrue((yield* mutation.pipe(Effect.result))._tag === "Failure"); + } + assert.deepEqual( + yield* sql`SELECT session_id, enrolled_at FROM native_creation_automation_enrollments`, + [{ session_id: "fixture-native-session", enrolled_at: "2026-10-02T12:00:00Z" }], + ); + }).pipe(Effect.provide(memory)), +); + +it.effect("bounded upstream replay leaves creation tables absent and migration is idempotent", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 54 }); + assert.deepEqual( + yield* sql`SELECT name FROM sqlite_master WHERE name LIKE 'native_creation_%'`, + [], + ); + yield* migrate; + yield* migrate; + yield* sql`INSERT INTO native_creation_intents + (claim_id, operation_id, preparation_id, command_id, thread_id, message_id, project_cwd, branch, worktree_path, canonical_preparation, intent_json) + VALUES ('claim', 'operation', 'preparation', 'command', 'thread', 'message', '/fixture/project', 'fixture-branch', '/fixture/path', '{}', '{}')`; + for (const mutation of [ + sql`UPDATE native_creation_intents SET intent_json = 'changed' WHERE claim_id = 'claim'`, + sql`DELETE FROM native_creation_intents WHERE claim_id = 'claim'`, + ]) { + assert.isTrue((yield* mutation.pipe(Effect.result))._tag === "Failure"); + } + assert.deepEqual(yield* sql`SELECT claim_id, intent_json FROM native_creation_intents`, [ + { claim_id: "claim", intent_json: "{}" }, + ]); + }).pipe(Effect.provide(memory)), +); + +it.effect( + "all resource identity constraints reject collisions and effect facts cannot replace history", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* migrate; + const insert = (id: string, column?: string) => + sql`INSERT INTO native_creation_intents ${sql.insert({ + claim_id: id, + operation_id: id, + preparation_id: id, + command_id: id, + thread_id: id, + message_id: id, + project_cwd: "/fixture/project", + branch: id, + worktree_path: `/fixture/${id}`, + canonical_preparation: "{}", + intent_json: "{}", + ...(column === undefined + ? {} + : { [column]: column === "worktree_path" ? "/fixture/original" : "original" }), + })}`; + yield* insert("original"); + for (const column of [ + "claim_id", + "operation_id", + "preparation_id", + "command_id", + "thread_id", + "message_id", + "branch", + "worktree_path", + ]) { + assert.isTrue( + (yield* insert(`other-${column}`, column).pipe(Effect.result))._tag === "Failure", + ); + } + yield* sql`INSERT INTO native_creation_effect_facts (claim_id, effect_id, phase, ordinal, fact_json) VALUES ('original', 'effect', 'started', 0, '{}')`; + for (const mutation of [ + sql`UPDATE native_creation_effect_facts SET fact_json = 'changed'`, + sql`DELETE FROM native_creation_effect_facts`, + sql`INSERT INTO native_creation_effect_facts (claim_id, effect_id, phase, ordinal, fact_json) VALUES ('original', 'effect', 'started', 1, 'changed')`, + sql`INSERT OR REPLACE INTO native_creation_effect_facts (claim_id, effect_id, phase, ordinal, fact_json) VALUES ('original', 'effect', 'started', 0, 'changed')`, + ]) { + assert.isTrue((yield* mutation.pipe(Effect.result))._tag === "Failure"); + } + assert.deepEqual(yield* sql`SELECT ordinal, fact_json FROM native_creation_effect_facts`, [ + { ordinal: 0, fact_json: "{}" }, + ]); + }).pipe(Effect.provide(memory)), +); diff --git a/apps/server/src/nativeCreation/NativeCreationRepositorySqlite.ts b/apps/server/src/nativeCreation/NativeCreationRepositorySqlite.ts new file mode 100644 index 000000000..928f2beb2 --- /dev/null +++ b/apps/server/src/nativeCreation/NativeCreationRepositorySqlite.ts @@ -0,0 +1,406 @@ +import { AuthSessionId, NativeCreationEffect, OrchestrationV2Command } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Authority from "./NativeCreationAuthority.ts"; +import * as Repository from "./NativeCreationRepository.ts"; +import { + nativeCreationCanonicalJson, + nativeCreationSha256, + validateNativeCreationPreparation, +} from "./NativeCreationPreparation.ts"; + +const fail = (message: string) => + new Repository.NativeCreationRepositoryError({ code: "conflict", message }); +const isRepositoryError = Schema.is(Repository.NativeCreationRepositoryError); +const isAuthorityError = Schema.is(Authority.NativeCreationAuthorityError); +const mapError = (cause: unknown) => + isRepositoryError(cause) || isAuthorityError(cause) + ? cause + : fail("Native creation persistence rejected the operation"); +const mapRepositoryError = (cause: unknown) => + isRepositoryError(cause) ? cause : fail("Native creation persistence rejected the operation"); +const decodeIntentJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Repository.NativeCreationStoredIntent), +); +const decodeEffectJson = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeCreationEffect)); +const decodeIntent = Schema.decodeUnknownEffect(Repository.NativeCreationStoredIntent); +const decodeCommand = Schema.decodeUnknownEffect(OrchestrationV2Command); +const decodeEffect = Schema.decodeUnknownEffect(NativeCreationEffect); +const decodeEnrollmentSessionId = Schema.decodeUnknownEffect(AuthSessionId); +const decodeEnrollmentRow = Schema.decodeUnknownEffect( + Schema.Struct({ + sessionId: AuthSessionId, + enrolledAt: Schema.DateTimeUtcFromString, + }), +); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const hasAutomationEnrollment: Repository.NativeCreationRepository["Service"]["hasAutomationEnrollment"] = + Effect.fn("NativeCreationRepository.hasAutomationEnrollment")( + function* (actorSessionId: AuthSessionId) { + const sessionId = yield* decodeEnrollmentSessionId(actorSessionId); + const rows = yield* sql<{ sessionId: string; enrolledAt: string }>` + SELECT session_id AS "sessionId", enrolled_at AS "enrolledAt" + FROM native_creation_automation_enrollments WHERE session_id = ${sessionId} + `; + if (rows.length === 0) return false; + if (rows.length !== 1) + return yield* fail("Native automation enrollment membership is inconsistent"); + const row = yield* decodeEnrollmentRow(rows[0]); + if (row.sessionId !== sessionId) + return yield* fail("Native automation enrollment session disagrees"); + return true; + }, + Effect.mapError( + () => + new Repository.NativeCreationRepositoryError({ + code: "unresolved_claim", + message: "Native automation enrollment membership is unavailable or malformed", + }), + ), + ); + const readByClaim = Effect.fnUntraced(function* (claimId: string) { + const rows = yield* sql<{ + intent_json: string; + }>`SELECT intent_json FROM native_creation_intents WHERE claim_id = ${claimId}`; + if (rows.length !== 1) return yield* fail("Native creation claim is missing"); + const intent = yield* decodeIntentJson(rows[0]!.intent_json); + const normalized = yield* sql<{ + command_digest: string; + }>`SELECT command_digest FROM native_creation_normalized_commands WHERE claim_id = ${claimId}`; + const facts = yield* sql<{ + fact_json: string; + }>`SELECT fact_json FROM native_creation_effect_facts WHERE claim_id = ${claimId} ORDER BY ordinal`; + const effects = yield* Effect.forEach(facts, (row) => decodeEffectJson(row.fact_json)); + return { + intent, + normalizedCommandDigest: normalized[0]?.command_digest ?? null, + effects, + } satisfies Repository.NativeCreationHistory; + }); + + const claim: Repository.NativeCreationRepository["Service"]["claim"] = (input, authorize) => + sql + .withTransaction( + Effect.gen(function* () { + const preparation = yield* validateNativeCreationPreparation( + new TextEncoder().encode(input.preparation.canonicalText), + ); + const binding = yield* authorize; + const expectedBinding = { + backendInstance: preparation.binding.backend_instance, + environmentId: preparation.binding.environment_id, + projectId: preparation.binding.project_id, + projectCwd: preparation.binding.project_cwd, + accountRef: preparation.binding.account_ref, + accountBindingId: binding.accountBindingId, + accountBindingRevision: binding.accountBindingRevision, + providerModelSelection: preparation.binding.provider_model_selection, + runtimeMode: preparation.binding.runtime_mode, + interactionMode: preparation.binding.interaction_mode, + baseBranch: preparation.binding.base_branch, + startFromOrigin: preparation.binding.start_from_origin, + runSetupScript: preparation.binding.run_setup_script, + requestedBranch: preparation.command.bootstrap.prepareWorktree.branch, + }; + if ( + !input.claimId || + !input.claimedBootId || + !input.actorSessionId || + !input.grantId || + !Number.isSafeInteger(input.grantRevision) || + input.grantRevision < 1 || + !Number.isFinite(Date.parse(input.claimedAt)) || + input.resources.projectCwd !== preparation.binding.project_cwd || + input.resources.branch !== preparation.command.bootstrap.prepareWorktree.branch || + !input.resources.worktreePath.startsWith("/") || + preparation.preparationSha256 !== input.preparation.preparationSha256 || + nativeCreationCanonicalJson(binding) !== nativeCreationCanonicalJson(expectedBinding) + ) { + return yield* fail("Native creation claim input disagrees with immutable intent"); + } + const existing = yield* sql<{ + claim_id: string; + }>`SELECT claim_id FROM native_creation_intents WHERE operation_id = ${preparation.operationId}`; + if (existing.length > 0) { + const history = yield* readByClaim(existing[0]!.claim_id); + if ( + history.intent.canonicalPreparation !== preparation.canonicalText || + history.intent.actorSessionId !== input.actorSessionId || + nativeCreationCanonicalJson(history.intent.resources) !== + nativeCreationCanonicalJson(input.resources) || + nativeCreationCanonicalJson(history.intent.binding) !== + nativeCreationCanonicalJson(binding) + ) { + return yield* fail( + "Native creation operation already has a different immutable intent", + ); + } + return { status: "duplicate" as const, history }; + } + const reservations = + yield* sql`SELECT command_id FROM native_creation_reserved_commands WHERE command_id = ${preparation.command.commandId}`; + if (reservations.length !== 0) + return yield* fail("Native creation command is already reserved by another intent"); + const intent: Repository.NativeCreationStoredIntent = { + claimId: input.claimId, + claimedBootId: input.claimedBootId, + claimedAt: input.claimedAt, + actorSessionId: input.actorSessionId, + grantId: input.grantId, + grantRevision: input.grantRevision, + preparationId: preparation.preparationId, + operationId: preparation.operationId, + preparationSha256: preparation.preparationSha256, + bindingDigest: preparation.bindingDigest, + promptDigest: preparation.promptDigest, + commandDigest: preparation.commandDigest, + commandId: preparation.command.commandId, + threadId: preparation.command.threadId, + messageId: preparation.command.message.messageId, + canonicalPreparation: preparation.canonicalText, + binding, + resources: input.resources, + }; + yield* decodeIntent(intent); + yield* sql`INSERT INTO native_creation_intents + (claim_id, operation_id, preparation_id, command_id, thread_id, message_id, project_cwd, branch, worktree_path, canonical_preparation, intent_json) + VALUES (${intent.claimId}, ${intent.operationId}, ${intent.preparationId}, ${intent.commandId}, ${intent.threadId}, ${intent.messageId}, + ${intent.resources.projectCwd}, ${intent.resources.branch}, ${intent.resources.worktreePath}, ${intent.canonicalPreparation}, ${nativeCreationCanonicalJson(intent)})`; + return { + status: "claimed" as const, + history: { intent, normalizedCommandDigest: null, effects: [] }, + }; + }), + ) + .pipe(Effect.mapError(mapError)); + + const readHistory: Repository.NativeCreationRepository["Service"]["readHistory"] = (commandId) => + sql + .withTransaction( + Effect.gen(function* () { + const rows = yield* sql<{ + claim_id: string; + }>`SELECT claim_id FROM native_creation_intents WHERE command_id = ${commandId}`; + if (rows.length === 0) return Option.none(); + return Option.some(yield* readByClaim(rows[0]!.claim_id)); + }), + ) + .pipe(Effect.mapError(mapRepositoryError)); + + const getReserved = Effect.fnUntraced(function* (commandId: string) { + const rows = yield* sql<{ + claim_id: string; + command_id: string; + thread_id: string; + command_type: Repository.NativeCreationReservedCommand["commandType"]; + command_digest: string; + canonical_command: string; + }>`SELECT claim_id, command_id, thread_id, command_type, command_digest, canonical_command + FROM native_creation_reserved_commands WHERE command_id = ${commandId}`; + const row = rows[0]; + return row === undefined + ? Option.none() + : Option.some({ + claimId: row.claim_id, + commandId: row.command_id, + threadId: row.thread_id, + commandType: row.command_type, + commandDigest: row.command_digest, + canonicalCommand: row.canonical_command, + }); + }); + + const reserve = Effect.fnUntraced(function* (claimId: string, input: OrchestrationV2Command) { + const command = yield* decodeCommand(input, { + onExcessProperty: "error", + }); + const { intent } = yield* readByClaim(claimId); + if ( + !("threadId" in command) || + command.threadId !== intent.threadId || + !["thread.create", "message.dispatch", "thread.delete"].includes(command.type) + ) { + return yield* fail("Creation command does not address the claimed thread"); + } + const owners = yield* sql<{ + claim_id: string; + }>`SELECT claim_id FROM native_creation_intents WHERE command_id = ${command.commandId}`; + if (owners.some((row) => row.claim_id !== claimId)) + return yield* fail("Creation command is claimed by another intent"); + const canonicalCommand = nativeCreationCanonicalJson(command); + const existing = yield* getReserved(command.commandId); + if (Option.isSome(existing)) { + if ( + existing.value.claimId !== claimId || + existing.value.canonicalCommand !== canonicalCommand + ) + return yield* fail("Reserved creation command is immutable"); + return; + } + yield* sql`INSERT INTO native_creation_reserved_commands (command_id, claim_id, thread_id, command_type, command_digest, canonical_command) + VALUES (${command.commandId}, ${claimId}, ${command.threadId}, ${command.type}, ${nativeCreationSha256(canonicalCommand)}, ${canonicalCommand})`; + }); + + const reserveCommand: Repository.NativeCreationRepository["Service"]["reserveCommand"] = ( + claimId, + command, + ) => sql.withTransaction(reserve(claimId, command)).pipe(Effect.mapError(mapRepositoryError)); + const recordNormalizedCommand: Repository.NativeCreationRepository["Service"]["recordNormalizedCommand"] = ( + claimId, + command, + ) => + sql + .withTransaction( + Effect.gen(function* () { + const { intent } = yield* readByClaim(claimId); + if (command.commandId !== intent.commandId || command.type !== "message.dispatch") + return yield* fail("Normalized command identity differs from intent"); + yield* reserve(claimId, command); + const canonicalCommand = nativeCreationCanonicalJson(command); + const existing = yield* sql<{ + canonical_command: string; + }>`SELECT canonical_command FROM native_creation_normalized_commands WHERE claim_id = ${claimId}`; + if (existing.length > 0) { + if (existing[0]!.canonical_command !== canonicalCommand) + return yield* fail("Normalized creation command is immutable"); + return; + } + yield* sql`INSERT INTO native_creation_normalized_commands (claim_id, command_digest, canonical_command) + VALUES (${claimId}, ${nativeCreationSha256(canonicalCommand)}, ${canonicalCommand})`; + }), + ) + .pipe(Effect.mapError(mapRepositoryError)); + + const append = Effect.fnUntraced(function* ( + claimId: string, + input: + | Parameters[1] + | Parameters[1], + ) { + const history = yield* readByClaim(claimId); + const fact = yield* decodeEffect({ + ...input, + ordinal: history.effects.length, + }); + const resources = history.intent.resources; + if ( + ("threadId" in fact && fact.threadId !== history.intent.threadId) || + ("projectCwd" in fact && fact.projectCwd !== resources.projectCwd) || + ("worktreePath" in fact && fact.worktreePath !== resources.worktreePath) || + ("branch" in fact && fact.branch !== resources.branch) + ) + return yield* fail("Creation effect addresses unclaimed resources"); + if (fact.kind === "native_command") { + const reserved = yield* getReserved(fact.commandId); + if ( + Option.isNone(reserved) || + reserved.value.claimId !== claimId || + reserved.value.commandDigest !== fact.commandDigest || + reserved.value.threadId !== fact.threadId || + reserved.value.commandType !== fact.commandType + ) + return yield* fail("Creation command effect is not reserved"); + } + if (fact.kind === "cleanup") { + const resource = fact.resource; + if ( + (resource.kind === "thread" && resource.threadId !== history.intent.threadId) || + (resource.kind !== "thread" && resource.worktreePath !== resources.worktreePath) || + (resource.kind === "worktree" && + (resource.projectCwd !== resources.projectCwd || resource.branch !== resources.branch)) + ) + return yield* fail("Creation cleanup addresses unclaimed resources"); + } + if (fact.phase === "completed") { + const started = history.effects.find( + (entry) => entry.effectId === fact.effectId && entry.phase === "started", + ); + if (started === undefined || started.kind !== fact.kind) + return yield* fail("Creation effect completion has no matching start"); + const ignored = new Set([ + "timestamp", + "ordinal", + "phase", + "result", + "eventId", + "sequence", + "exitCode", + "terminalId", + "ownership", + ]); + for (const [key, value] of Object.entries(started)) { + if ( + !ignored.has(key) && + nativeCreationCanonicalJson(value) !== nativeCreationCanonicalJson(Reflect.get(fact, key)) + ) + return yield* fail("Creation effect completion disagrees with start"); + } + if ( + started.kind === "setup" && + fact.kind === "setup" && + started.terminalId !== null && + started.terminalId !== fact.terminalId + ) { + return yield* fail("Setup completion differs from the known started terminal"); + } + } + yield* sql`INSERT INTO native_creation_effect_facts (claim_id, effect_id, phase, ordinal, fact_json) + VALUES (${claimId}, ${fact.effectId}, ${fact.phase}, ${fact.ordinal}, ${nativeCreationCanonicalJson(fact)})`; + return fact; + }); + + const startEffect: Repository.NativeCreationRepository["Service"]["startEffect"] = ( + claimId, + fact, + authorize, + ) => + sql + .withTransaction( + Effect.gen(function* () { + const binding = yield* authorize; + const { intent } = yield* readByClaim(claimId); + if (nativeCreationCanonicalJson(binding) !== nativeCreationCanonicalJson(intent.binding)) + return yield* fail("Current authority binding differs from claimed binding"); + if (fact.phase !== "started") + return yield* fail("Start requires a started creation effect"); + // The committed start authorizes the subsequent external action; SQL and external effects are not atomic. + const persisted = yield* append(claimId, fact); + if (persisted.phase !== "started") + return yield* fail("Persisted fact phase differs from start"); + return persisted; + }), + ) + .pipe(Effect.mapError(mapError)); + const completeEffect: Repository.NativeCreationRepository["Service"]["completeEffect"] = (claimId, fact) => + sql + .withTransaction( + Effect.gen(function* () { + if (fact.phase !== "completed") + return yield* fail("Completion requires a completed creation effect"); + const persisted = yield* append(claimId, fact); + if (persisted.phase !== "completed") + return yield* fail("Persisted fact phase differs from completion"); + return persisted; + }), + ) + .pipe(Effect.mapError(mapRepositoryError)); + + return Repository.NativeCreationRepository.of({ + hasAutomationEnrollment, + claim, + readHistory, + reserveCommand, + recordNormalizedCommand, + getReservedCommand: (commandId) => + getReserved(commandId).pipe(Effect.mapError(mapRepositoryError)), + startEffect, + completeEffect, + }); +}); + +export const layer = Layer.effect(Repository.NativeCreationRepository, make); diff --git a/knip.jsonc b/knip.jsonc index 386b71089..fde4a2e47 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -34,6 +34,11 @@ "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", + // Staged native creation substrate is consumed by the deferred L39 execution slice. + // Analyzer entries preserve these APIs without registering runtime authority or launch. + "src/nativeCreation/NativeCreationPreparation.ts", + "src/nativeCreation/NativeCreationAuthority.ts", + "src/nativeCreation/NativeCreationRepositorySqlite.ts", ], // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9a31f0888..b7b2cc1d0 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -88,7 +88,28 @@ export const ServerSelfUpdateCapability = Schema.Literals([ ]); export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; +const nativeBootstrapCapabilityStruct = (fields: Fields) => { + const schema = Schema.Struct(fields); + // Flipped checks validate original wire keys that ordinary struct decoding would strip. + return Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter((value) => + Reflect.ownKeys(value).every((key) => Object.hasOwn(fields, key)), + ), + ), + ); +}; + +export const NativeBootstrapCreationCapability = nativeBootstrapCapabilityStruct({ + submissionSchema: Schema.Literal("t3.native-bootstrap-submission/v1"), + preparationSchema: Schema.Literal("voice.t3-bootstrap-preparation/v1"), + observationSchema: Schema.Literal("t3.native-creation-observation/v1"), + guardRequired: Schema.Literal(true), +}); +export type NativeBootstrapCreationCapability = typeof NativeBootstrapCreationCapability.Type; + export const ExecutionEnvironmentCapabilities = Schema.Struct({ + nativeBootstrapCreation: Schema.optionalKey(NativeBootstrapCreationCapability), repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8690bb1b2..697fcbe1b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -33,6 +33,7 @@ export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; export * from "./orchestrationDispatch.ts"; +export * from "./nativeCreation.ts"; export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.ts"; export * from "./applicationEvent.ts"; diff --git a/packages/contracts/src/nativeCreation.test.ts b/packages/contracts/src/nativeCreation.test.ts new file mode 100644 index 000000000..a540b5e67 --- /dev/null +++ b/packages/contracts/src/nativeCreation.test.ts @@ -0,0 +1,402 @@ +import { assert, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { + ExecutionEnvironmentCapabilities, + NativeBootstrapCreationCapability, +} from "./environment.ts"; +import { + NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES, + NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES, + NativeBootstrapSubmission, + NativeCreationEffect, + NativeCreationGuard, + NativeCreationObservation, +} from "./nativeCreation.ts"; +import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; +import { OrchestrationV2ThreadLaunchResult, OrchestrationV2DispatchCommandError } from "./orchestrationV2.ts"; + +const guard = { + schema: "t3.native-creation-guard/v1", + grantId: "grant-1", + grantRevision: 1, +}; +const submission = { + schema: "t3.native-bootstrap-submission/v1", + preparationBase64: "e30=", + creationGuard: guard, +}; +const capability = { + submissionSchema: "t3.native-bootstrap-submission/v1", + preparationSchema: "voice.t3-bootstrap-preparation/v1", + observationSchema: "t3.native-creation-observation/v1", + guardRequired: true, +}; + +const digest = "a".repeat(64); +const creation = { + schema: "t3.native-creation-observation/v1", + preparationId: "preparation-1", + operationId: "operation-1", + preparationSha256: digest, + bindingDigest: digest, + promptDigest: digest, + commandDigest: digest, + normalizedCommandDigest: digest, + claimId: "claim-1", + claimedBootId: "boot-1", + claimedAt: "2026-10-02T12:00:00Z", + actorSessionId: "session-1", + grantId: "grant-1", + grantRevision: 1, + binding: { + backendInstance: "backend-1", + environmentId: "environment-1", + projectId: "project-1", + projectCwd: "/workspace/project", + accountRef: "account-ref-1", + accountBindingId: "account-binding-1", + accountBindingRevision: 1, + providerModelSelection: { + instanceId: "codex", + model: "model-1", + options: [ + { id: "fast", value: true }, + { id: "effort", value: "high" }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + baseBranch: "main", + startFromOrigin: true, + runSetupScript: false, + requestedBranch: "t3code/voice-branch", + }, + incarnation: null, + effects: [], + unresolvedEffects: [], + outcome: "unknown", +}; +const acceptsWire = >(schema: S) => { + const decode = Schema.decodeUnknownOption(schema); + return (input: unknown) => Option.isSome(decode(input)); +}; + +const effect = { effectId: "effect-1", ordinal: 0, timestamp: creation.claimedAt }; +const decodeSubmission = Schema.decodeUnknownSync(NativeBootstrapSubmission); +const encodeSubmission = Schema.encodeSync(NativeBootstrapSubmission); +const decodeCreation = Schema.decodeUnknownSync(NativeCreationObservation); +const encodeCreation = Schema.encodeSync(NativeCreationObservation); + +it("requires a closed guarded submission and a positive safe grant revision", () => { + const accepts = acceptsWire(NativeBootstrapSubmission); + assert.isTrue(accepts(submission)); + const decoded = decodeSubmission(submission); + assert.deepEqual(encodeSubmission(decoded), submission); + assert.isFalse(accepts({ ...submission, creationGuard: undefined })); + assert.isFalse(accepts({ ...submission, extra: true })); + assert.isFalse(accepts({ ...submission, creationGuard: { ...guard, extra: true } })); + for (const grantRevision of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, NaN]) { + assert.isFalse(acceptsWire(NativeCreationGuard)({ ...guard, grantRevision })); + } + assert.isTrue( + acceptsWire(NativeCreationGuard)({ ...guard, grantRevision: Number.MAX_SAFE_INTEGER }), + ); +}); + +it("bounds decoded preparation bytes, rejects malformed base64 and bounds submission UTF-8", () => { + const accepts = acceptsWire(NativeBootstrapSubmission); + const atLimit = btoa("\0".repeat(NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES)); + assert.isTrue(accepts({ ...submission, preparationBase64: atLimit })); + assert.isFalse( + accepts({ + ...submission, + preparationBase64: btoa("\0".repeat(NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES + 1)), + }), + ); + for (const preparationBase64 of ["", "e30", "e30=\n", "-___", "====", "A==="]) { + assert.isFalse(accepts({ ...submission, preparationBase64 })); + } + assert.isFalse( + accepts({ + ...submission, + creationGuard: { ...guard, grantId: "é".repeat(NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES / 2) }, + }), + ); +}); + +it("keeps legacy capabilities and observations decodable while requiring exact creation versions", () => { + const decodeCapabilities = Schema.decodeUnknownSync(ExecutionEnvironmentCapabilities); + assert.deepEqual(decodeCapabilities({}), { repositoryIdentity: false }); + assert.deepEqual( + decodeCapabilities({ nativeBootstrapCreation: capability }).nativeBootstrapCreation, + capability, + ); + const acceptsCapability = acceptsWire(NativeBootstrapCreationCapability); + assert.isFalse(acceptsCapability({ ...capability, extra: true })); + assert.isFalse(acceptsCapability({ ...capability, guardRequired: false })); + for (const field of ["submissionSchema", "preparationSchema", "observationSchema"]) { + assert.isFalse(acceptsCapability({ ...capability, [field]: "unsupported/v2" })); + } + const decodeCreationField = Schema.decodeUnknownSync( + Schema.Struct({ creation: OrchestrationV2ThreadLaunchResult.fields.creation }), + ); + assert.deepEqual(decodeCreationField({}), {}); + assert.deepEqual(decodeCreationField({ creation }).creation, creation); +}); + +it("records historical unknown effects without prompt bytes or legacy provider normalization", () => { + const accepts = acceptsWire(NativeCreationObservation); + const started = { + ...effect, + kind: "fetch", + phase: "started", + projectCwd: "/workspace/project", + baseRef: "origin/main", + }; + assert.isTrue(accepts({ ...creation, effects: [started], unresolvedEffects: [effect.effectId] })); + assert.isFalse(accepts({ ...creation, prompt: "private prompt" })); + assert.isFalse(accepts({ ...creation, preparationBase64: submission.preparationBase64 })); + assert.isFalse( + accepts({ + ...creation, + binding: { + ...creation.binding, + providerModelSelection: { provider: "codex", model: "model-1" }, + }, + }), + ); + assert.isFalse( + accepts({ + ...creation, + binding: { + ...creation.binding, + providerModelSelection: { instanceId: "codex", model: "model-1", options: { fast: true } }, + }, + }), + ); + assert.isFalse(accepts({ ...creation, incarnation: { eventId: "event-1" } })); + assert.isFalse(accepts({ ...creation, preparationSha256: "not-a-digest" })); + for (const binding of [ + { ...creation.binding, extra: true }, + { + ...creation.binding, + providerModelSelection: { ...creation.binding.providerModelSelection, extra: true }, + }, + { + ...creation.binding, + providerModelSelection: { + ...creation.binding.providerModelSelection, + options: [{ id: "fast", value: true, extra: true }], + }, + }, + ]) { + assert.isFalse(accepts({ ...creation, binding })); + } + assert.isFalse( + accepts({ ...creation, incarnation: { eventId: "event-1", sequence: 1, extra: true } }), + ); + const decoded = decodeCreation(creation); + assert.deepEqual(encodeCreation(decoded), creation); +}); + +it("requires stage-specific effect facts and preserves failed and unknown external results", () => { + const accepts = acceptsWire(NativeCreationEffect); + const fetch = { + ...effect, + kind: "fetch", + projectCwd: "/workspace/project", + baseRef: "origin/main", + }; + assert.isFalse(accepts({ ...fetch, phase: "completed" })); + for (const result of ["succeeded", "failed", "unknown"]) { + assert.isTrue(accepts({ ...fetch, phase: "completed", result })); + } + assert.isFalse(accepts({ ...fetch, phase: "started", details: {} })); + assert.isFalse(accepts({ ...effect, kind: "arbitrary", phase: "started" })); + for (const commandType of [ + "thread.create", + "thread.message.user.append", + "thread.session.set", + "thread.meta.update", + "thread.turn.start", + "thread.delete", + ]) { + const command = { + ...effect, + kind: "native_command", + commandId: "command-1", + threadId: "thread-1", + commandType, + commandDigest: digest, + }; + assert.isTrue(accepts({ ...command, phase: "started" })); + assert.isFalse(accepts({ ...command, phase: "completed" })); + assert.isTrue(accepts({ ...command, phase: "completed", eventId: "event-1", sequence: 1 })); + } + const worktree = { + projectCwd: "/workspace/project", + worktreePath: "/native/worktrees/project/voice", + branch: "t3code/voice-branch", + baseRef: "origin/main", + ownership: "claimed", + }; + assert.isTrue(accepts({ ...effect, kind: "worktree", phase: "started", ...worktree })); + assert.isTrue( + accepts({ ...effect, kind: "worktree", phase: "completed", ...worktree, result: "unknown" }), + ); + assert.isTrue( + accepts({ + ...effect, + kind: "setup", + phase: "started", + worktreePath: worktree.worktreePath, + terminalId: null, + }), + ); + assert.isTrue( + accepts({ + ...effect, + kind: "setup", + phase: "completed", + worktreePath: worktree.worktreePath, + terminalId: "terminal-1", + exitCode: 1, + result: "failed", + }), + ); + assert.isTrue( + accepts({ + ...effect, + kind: "cleanup", + phase: "completed", + resource: { + kind: "thread", + threadId: "thread-1", + incarnation: { eventId: "created-event-1", sequence: 1 }, + }, + recoveryScopeId: "recovery-1", + result: "unknown", + }), + ); + for (const action of [ + "tracker_registration", + "bootstrap_detachment", + "setup_detachment", + "setup_completion_detachment", + ]) { + assert.isTrue( + accepts({ ...effect, kind: "lifecycle", phase: "started", threadId: "thread-1", action }), + ); + } + assert.isFalse( + accepts({ + ...effect, + kind: "cleanup", + phase: "started", + resource: { kind: "thread", threadId: "thread-1" }, + recoveryScopeId: "recovery-1", + }), + ); + assert.isFalse(accepts({ ...effect, kind: "setup", phase: "completed", result: "failed" })); +}); + +it("keeps dispatch rejection codes optional and closed", () => { + const decode = Schema.decodeUnknownSync(OrchestrationDispatchCommandError); + const error = { _tag: "OrchestrationDispatchCommandError", message: "Rejected" }; + assert.equal(decode(error).creationRejectionCode, undefined); + assert.equal( + decode({ ...error, creationRejectionCode: "stale_grant" }).creationRejectionCode, + "stale_grant", + ); + assert.throws(() => decode({ ...error, creationRejectionCode: "invented" })); +}); + + +it("keeps V2 dispatch rejection codes optional without altering current error fields", () => { + const decode = Schema.decodeUnknownSync(OrchestrationV2DispatchCommandError); + const error = { + _tag: "OrchestrationV2DispatchCommandError", + commandId: "command-1", + commandType: "message.dispatch", + message: "Rejected", + }; + assert.equal(decode(error).creationRejectionCode, undefined); + assert.equal(decode({ ...error, creationRejectionCode: "stale_grant" }).creationRejectionCode, "stale_grant"); + assert.throws(() => decode({ ...error, creationRejectionCode: "invented" })); +}); + +it("does not reinterpret V2 execution commands as historical native command facts", () => { + const accepts = acceptsWire(NativeCreationEffect); + assert.isFalse(accepts({ + ...effect, + kind: "native_command", + phase: "started", + commandId: "command-1", + threadId: "thread-1", + commandType: "message.dispatch", + commandDigest: digest, + })); +}); + + +it("round-trips current launch results in both upgrade directions without changing historical bytes", () => { + const now = DateTime.makeUnsafe("2026-10-02T12:00:00Z"); + const projection = { + thread: { + createdBy: "user", + creationSource: "server", + id: "thread-1", + projectId: "project-1", + title: "Synthetic thread", + providerInstanceId: "codex", + modelSelection: { instanceId: "codex", model: "fixture-model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; + const beforeCreation = Schema.Struct({ + threadId: OrchestrationV2ThreadLaunchResult.fields.threadId, + projection: OrchestrationV2ThreadLaunchResult.fields.projection, + resumed: OrchestrationV2ThreadLaunchResult.fields.resumed, + }); + const decode = Schema.decodeUnknownSync(OrchestrationV2ThreadLaunchResult); + const legacy = { threadId: "thread-1", projection, resumed: false }; + const acceptedLegacy = decode(legacy); + assert.equal(acceptedLegacy.creation, undefined); + assert.deepEqual(acceptedLegacy, Schema.decodeUnknownSync(beforeCreation)(legacy)); + const accepted = decode({ ...legacy, creation }); + assert.deepEqual(Schema.encodeSync(NativeCreationObservation)(accepted.creation!), creation); + assert.deepEqual( + Schema.decodeUnknownSync(beforeCreation)({ ...legacy, creation }), + acceptedLegacy, + ); + assert.throws(() => decode({ ...legacy, creation: { ...creation, extra: true } })); +}); diff --git a/packages/contracts/src/nativeCreation.ts b/packages/contracts/src/nativeCreation.ts new file mode 100644 index 000000000..a72009ce9 --- /dev/null +++ b/packages/contracts/src/nativeCreation.ts @@ -0,0 +1,275 @@ +import * as Schema from "effect/Schema"; + +import { CommandId, EventId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; +import { RuntimeMode, ProviderInteractionMode } from "./providerPolicy.ts"; + +const nativeCreationStruct = (fields: Fields) => { + const schema = Schema.Struct(fields); + // Flipped checks validate original wire keys that ordinary struct decoding would strip. + return Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter((value) => + Reflect.ownKeys(value).every((key) => Object.hasOwn(fields, key)), + ), + ), + ); +}; + +export const NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES = 1_048_576; +export const NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES = 2_097_152; + +const NativeCreationRevision = Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), +); +const NativeCreationString = Schema.String.check(Schema.isNonEmpty()); +const NativeCreationSha256 = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); + +export const NativeCreationGuard = nativeCreationStruct({ + schema: Schema.Literal("t3.native-creation-guard/v1"), + grantId: NativeCreationString, + grantRevision: NativeCreationRevision, +}); +export type NativeCreationGuard = typeof NativeCreationGuard.Type; + +export const NativeBootstrapSubmission = nativeCreationStruct({ + schema: Schema.Literal("t3.native-bootstrap-submission/v1"), + preparationBase64: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(4 * Math.ceil(NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES / 3)), + Schema.isPattern(/^[A-Za-z0-9+/]*={0,2}$/), + Schema.makeFilter((value) => { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return ( + value.length % 4 === 0 && + (value.length / 4) * 3 - padding <= NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES + ); + }), + ), + creationGuard: NativeCreationGuard, +}).check( + Schema.makeFilter( + (value) => + new TextEncoder().encode(JSON.stringify(value)).byteLength <= + NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES, + ), +); +export type NativeBootstrapSubmission = typeof NativeBootstrapSubmission.Type; + +export const NativeCreationRejectionCode = Schema.Literals([ + "unsupported_authority", + "invalid_preparation", + "stale_grant", + "binding_mismatch", + "conflict", + "unresolved_claim", +]); +export type NativeCreationRejectionCode = typeof NativeCreationRejectionCode.Type; + +const NativeCreationModelSelection = nativeCreationStruct({ + instanceId: ProviderInstanceId, + model: NativeCreationString, + options: Schema.optionalKey( + Schema.Array( + nativeCreationStruct({ + id: NativeCreationString, + value: Schema.Union([NativeCreationString, Schema.Boolean]), + }), + ), + ), +}); + +export const NativeCreationHistoricalBinding = nativeCreationStruct({ + backendInstance: NativeCreationString, + environmentId: NativeCreationString, + projectId: ProjectId, + projectCwd: NativeCreationString, + accountRef: NativeCreationString, + accountBindingId: NativeCreationString, + accountBindingRevision: NativeCreationRevision, + providerModelSelection: NativeCreationModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + baseBranch: NativeCreationString, + startFromOrigin: Schema.Boolean, + runSetupScript: Schema.Boolean, + requestedBranch: NativeCreationString, +}); +export type NativeCreationHistoricalBinding = typeof NativeCreationHistoricalBinding.Type; + +const NativeCreationIncarnation = nativeCreationStruct({ + eventId: EventId, + sequence: NonNegativeInt, +}); +const NativeCreationEffectBase = { + effectId: NativeCreationString, + ordinal: NonNegativeInt, + timestamp: IsoDateTime, +}; +const NativeCreationCommandDetails = { + commandId: CommandId, + threadId: ThreadId, + commandType: Schema.Literals([ + "thread.create", + "thread.meta.update", + "thread.message.user.append", + "thread.session.set", + "thread.turn.start", + "thread.delete", + ]), + commandDigest: NativeCreationSha256, +}; +const NativeCreationWorktreeDetails = { + projectCwd: NativeCreationString, + worktreePath: NativeCreationString, + branch: NativeCreationString, + baseRef: NativeCreationString, + ownership: Schema.Literals(["claimed", "created", "unknown"]), +}; +const NativeCreationCleanupDetails = { + resource: Schema.Union([ + nativeCreationStruct({ + kind: Schema.Literal("worktree"), + ...NativeCreationWorktreeDetails, + }), + nativeCreationStruct({ + kind: Schema.Literal("setup_terminal"), + terminalId: NativeCreationString, + worktreePath: NativeCreationString, + }), + nativeCreationStruct({ + kind: Schema.Literal("thread"), + threadId: ThreadId, + incarnation: NativeCreationIncarnation, + }), + ]), + recoveryScopeId: NativeCreationString, +}; +const NativeCreationExternalResult = Schema.Literals(["succeeded", "failed", "unknown"]); + +const NativeCreationLifecycleDetails = { + threadId: ThreadId, + action: Schema.Literals([ + "normalization", + "tracker_registration", + "bootstrap_detachment", + "setup_detachment", + "setup_completion_detachment", + "worktree_ownership", + "deletion_drain", + "git_status_refresh", + ]), +}; + +export const NativeCreationEffect = Schema.Union([ + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("lifecycle"), + phase: Schema.Literal("started"), + ...NativeCreationLifecycleDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("lifecycle"), + phase: Schema.Literal("completed"), + ...NativeCreationLifecycleDetails, + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("native_command"), + phase: Schema.Literal("started"), + ...NativeCreationCommandDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("native_command"), + phase: Schema.Literal("completed"), + ...NativeCreationCommandDetails, + eventId: EventId, + sequence: NonNegativeInt, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("fetch"), + phase: Schema.Literal("started"), + projectCwd: NativeCreationString, + baseRef: NativeCreationString, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("fetch"), + phase: Schema.Literal("completed"), + projectCwd: NativeCreationString, + baseRef: NativeCreationString, + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("worktree"), + phase: Schema.Literal("started"), + ...NativeCreationWorktreeDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("worktree"), + phase: Schema.Literal("completed"), + ...NativeCreationWorktreeDetails, + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("setup"), + phase: Schema.Literal("started"), + worktreePath: NativeCreationString, + terminalId: Schema.NullOr(NativeCreationString), + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("setup"), + phase: Schema.Literal("completed"), + worktreePath: NativeCreationString, + terminalId: Schema.NullOr(NativeCreationString), + exitCode: Schema.NullOr(Schema.Int), + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("cleanup"), + phase: Schema.Literal("started"), + ...NativeCreationCleanupDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("cleanup"), + phase: Schema.Literal("completed"), + ...NativeCreationCleanupDetails, + result: NativeCreationExternalResult, + }), +]); +export type NativeCreationEffect = typeof NativeCreationEffect.Type; + +/** Historical creation attestation does not attest a terminal turn or release capacity. */ +export const NativeCreationObservation = nativeCreationStruct({ + schema: Schema.Literal("t3.native-creation-observation/v1"), + preparationId: NativeCreationString, + operationId: NativeCreationString, + preparationSha256: NativeCreationSha256, + bindingDigest: NativeCreationSha256, + promptDigest: NativeCreationSha256, + commandDigest: NativeCreationSha256, + normalizedCommandDigest: NativeCreationSha256, + claimId: NativeCreationString, + claimedBootId: NativeCreationString, + claimedAt: IsoDateTime, + actorSessionId: NativeCreationString, + grantId: NativeCreationString, + grantRevision: NativeCreationRevision, + binding: NativeCreationHistoricalBinding, + incarnation: Schema.NullOr(NativeCreationIncarnation), + effects: Schema.Array(NativeCreationEffect), + unresolvedEffects: Schema.Array(NativeCreationString), + outcome: Schema.Literals(["complete", "in_progress", "incomplete", "unknown"]), +}); +export type NativeCreationObservation = typeof NativeCreationObservation.Type; + diff --git a/packages/contracts/src/orchestrationDispatch.ts b/packages/contracts/src/orchestrationDispatch.ts index 0dec60e48..4d12ff0b0 100644 --- a/packages/contracts/src/orchestrationDispatch.ts +++ b/packages/contracts/src/orchestrationDispatch.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; +import { NativeCreationRejectionCode } from "./nativeCreation.ts"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; /** @@ -11,6 +12,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError()( "OrchestrationV2DispatchCommandError", { + creationRejectionCode: Schema.optionalKey(NativeCreationRejectionCode), commandId: CommandId, commandType: Schema.String, message: Schema.String, From 38c1442d0f1a3830e99e75c46b9ee00318f984ba Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 04:17:43 +0200 Subject: [PATCH 16/59] feat(usage): restore saved token accounting and date details --- apps/server/src/auth/RpcAuthorization.test.ts | 4 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.ts | 5 + .../src/tokenAccounting/ProcessReader.test.ts | 613 ++++++++++++++++ .../src/tokenAccounting/ProcessReader.ts | 332 +++++++++ .../ProcessReaderConfig.test.ts | 293 ++++++++ .../tokenAccounting/ProcessReaderConfig.ts | 345 +++++++++ apps/server/src/tokenAccounting/Reader.ts | 79 ++ .../src/tokenAccounting/RuntimeReader.test.ts | 242 ++++++ .../src/tokenAccounting/RuntimeReader.ts | 53 ++ .../TokenAccountingRpc.test.ts | 71 ++ .../TokenAccountingService.test.ts | 400 ++++++++++ .../tokenAccounting/TokenAccountingService.ts | 168 +++++ apps/server/src/usage/UsageService.test.ts | 77 +- apps/server/src/usage/UsageService.ts | 22 +- .../server/src/usage/usageAggregation.test.ts | 26 + apps/server/src/usage/usageAggregation.ts | 35 +- apps/server/src/ws.ts | 11 +- .../usage/SavedTokenAccounting.test.tsx | 607 +++++++++++++++ .../components/usage/SavedTokenAccounting.tsx | 694 ++++++++++++++++++ .../usage/UsagePage.refresh.test.tsx | 1 + .../src/components/usage/UsagePage.test.tsx | 490 ++++++++++++- apps/web/src/components/usage/UsagePage.tsx | 458 ++++++++++-- .../UsageProviderChart.interaction.test.tsx | 97 +++ .../components/usage/UsageProviderChart.tsx | 22 +- .../components/usage/UsageProviderDetails.tsx | 114 +++ .../components/usage/usageBreakdown.test.ts | 110 ++- .../src/components/usage/usageBreakdown.ts | 42 +- .../components/usage/usageDateRange.test.ts | 168 +++++ .../src/components/usage/usageDateRange.ts | 110 +++ apps/web/src/state/tokenAccounting.test.ts | 90 +++ apps/web/src/state/tokenAccounting.ts | 128 ++++ .../client-runtime/src/rpc/client.test.ts | 117 +++ packages/client-runtime/src/rpc/client.ts | 13 +- packages/client-runtime/src/state/runtime.ts | 4 +- packages/client-runtime/src/state/server.ts | 22 + .../src/state/serverTokenAccounting.test.ts | 251 +++++++ packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 9 + .../contracts/src/tokenAccounting.test.ts | 219 ++++++ packages/contracts/src/tokenAccounting.ts | 517 +++++++++++++ packages/contracts/src/usage.ts | 8 +- packages/shared/src/usageMerge.test.ts | 203 +++++ packages/shared/src/usageMerge.ts | 60 +- 46 files changed, 7236 insertions(+), 108 deletions(-) create mode 100644 apps/server/src/tokenAccounting/ProcessReader.test.ts create mode 100644 apps/server/src/tokenAccounting/ProcessReader.ts create mode 100644 apps/server/src/tokenAccounting/ProcessReaderConfig.test.ts create mode 100644 apps/server/src/tokenAccounting/ProcessReaderConfig.ts create mode 100644 apps/server/src/tokenAccounting/Reader.ts create mode 100644 apps/server/src/tokenAccounting/RuntimeReader.test.ts create mode 100644 apps/server/src/tokenAccounting/RuntimeReader.ts create mode 100644 apps/server/src/tokenAccounting/TokenAccountingRpc.test.ts create mode 100644 apps/server/src/tokenAccounting/TokenAccountingService.test.ts create mode 100644 apps/server/src/tokenAccounting/TokenAccountingService.ts create mode 100644 apps/web/src/components/usage/SavedTokenAccounting.test.tsx create mode 100644 apps/web/src/components/usage/SavedTokenAccounting.tsx create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx create mode 100644 apps/web/src/components/usage/UsageProviderDetails.tsx create mode 100644 apps/web/src/components/usage/usageDateRange.test.ts create mode 100644 apps/web/src/components/usage/usageDateRange.ts create mode 100644 apps/web/src/state/tokenAccounting.test.ts create mode 100644 apps/web/src/state/tokenAccounting.ts create mode 100644 packages/client-runtime/src/state/serverTokenAccounting.test.ts create mode 100644 packages/contracts/src/tokenAccounting.test.ts create mode 100644 packages/contracts/src/tokenAccounting.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index fc355f66a..70f1e3497 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -19,6 +19,10 @@ import { } from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { + it("reads saved accounting under orchestration read permission", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.serverReadTokenAccounting)).toBe(AuthOrchestrationReadScope); + }); + it("declares exactly one scope for every RPC in the server group", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 54d8e2a90..594f8ca1f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -84,6 +84,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverReadTokenAccounting]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9e9d5c5e3..d7b8585c4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -150,6 +150,8 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as TokenAccountingService from "./tokenAccounting/TokenAccountingService.ts"; +import { makeRuntimeReader } from "./tokenAccounting/RuntimeReader.ts"; import { OrchestrationEventInfrastructureLayerLive, OrchestrationV2ProductionLayerLive, @@ -616,6 +618,9 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), Layer.provideMerge(UsageLayerLive), + Layer.provideMerge( + Layer.suspend(() => TokenAccountingService.layerWithReader(makeRuntimeReader(process.env))), + ), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/tokenAccounting/ProcessReader.test.ts b/apps/server/src/tokenAccounting/ProcessReader.test.ts new file mode 100644 index 000000000..bd1752410 --- /dev/null +++ b/apps/server/src/tokenAccounting/ProcessReader.test.ts @@ -0,0 +1,613 @@ +import { + TOKEN_ACCOUNTING_CAVEATS, + TOKEN_ACCOUNTING_IDENTITY_ALGORITHM, + TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + TOKEN_ACCOUNTING_PROJECTION_SCHEMA, + TOKEN_ACCOUNTING_REPORT_SCHEMA, + type TokenAccountingReport, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { afterEach, expect, it, vi } from "@effect/vitest"; + +import { + makeProcessReader, + type ProcessReaderChild, + type ProcessReaderRuntime, +} from "./ProcessReader.ts"; +import * as Config from "./ProcessReaderConfig.ts"; +import { TOKEN_ACCOUNTING_READER_LIMITS } from "./Reader.ts"; + +const reportId = "a".repeat(64); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const config = { bindingPath: "/fixture/binding.json", bindingSha256: "b".repeat(64), reportId }; +const binding: Config.ProcessReaderBinding = { + schema: "programmatic-token-info.saved-accounting-binding/v1", + uid: 42, + machine_id_sha256: "c".repeat(64), + python: { path: "/fixture/python", sha256: "d".repeat(64) }, + helper: { path: "/fixture/src/codex_v3/token_info/saved_reader.py", sha256: "e".repeat(64) }, + source_root: "/fixture/src", + archive_root: "/fixture/archive", + report_id: reportId, + source_closure: Object.fromEntries( + Config.TOKEN_ACCOUNTING_SOURCE_PATHS.map((path) => [path, "e".repeat(64)]), + ) as Config.ProcessReaderBinding["source_closure"], + source_closure_sha256: "f".repeat(64), + authority_effect: "none", +}; + +// Synthetic projection identities prove transport only; the canonical Python helper proves source identity. +function report(): TokenAccountingReport { + const metric = { known_sum: null, total: null, known_requests: 0, missing_requests: 0 }; + const metrics = { + input_tokens: metric, + cached_input_tokens: metric, + cache_write_input_tokens: metric, + cache_write_5m_tokens: metric, + cache_write_1h_tokens: metric, + output_tokens: metric, + reasoning_output_tokens: metric, + }; + const statuses = { primary: 0, legacy_unresolved: 0, conflict: 0, aggregate_delta: 0 }; + const partition = { requests: 0, metrics: { ...metrics, ordinary_input: metric } }; + const allocation = { + allocated: metric, + unknown: metric, + groups: [], + unknown_by_reason: {}, + estimated_coverage: null, + }; + return { + schema: TOKEN_ACCOUNTING_PROJECTION_SCHEMA, + source_schema: TOKEN_ACCOUNTING_REPORT_SCHEMA, + source_identity_algorithm: TOKEN_ACCOUNTING_IDENTITY_ALGORITHM, + source_identity_verified: true, + report_id: reportId, + selection_id: "b".repeat(64), + snapshot: { index_snapshot_id: "c".repeat(64) }, + window: { start: "2026-09-01T00:00:00Z", end: "2026-09-02T00:00:00Z", end_exclusive: true }, + provider_coverage: { + selected_requests: 0, + accounting_status_counts: statuses, + token_missingness: { + input_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + cache_write_5m_tokens: 0, + cache_write_1h_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + }, + latest_scan: { + captured_at: null, + selected_files: null, + refreshed_selected_files: null, + partial_selected_files: null, + root_scope_ids: [], + mtime_cutoff: null, + receipts_status_counts: null, + validated_files: null, + }, + cumulative_index: { + tracked_files: null, + retained_not_refreshed_files: null, + missing_tracked_files: null, + }, + freshness: { + status: "scan_unavailable", + requested_end: null, + last_scan_captured_at: null, + selected_validation_min: null, + selected_validation_max: null, + unrefreshed_files: null, + }, + historical_window_completeness: "not_proven", + }, + provider_ledger: { + requests: 0, + primary_requests: 0, + nonadditive_requests: 0, + accounting_status_counts: statuses, + metrics: { ...metrics, ordinary_input_tokens: metric, non_read_input_tokens: metric }, + }, + input: { ordinary_input: metric, ...allocation }, + output: { output: metric, measured_reasoning: metric, ...allocation }, + partitions: { + provider: { codex: partition, claude: partition }, + role: { root: partition, child: partition, unknown: partition }, + }, + mechanism_flags: { nonadditive: true, counts: {} }, + estimator_status_counts: { qualified: 0, unavailable: 0, failed: 0 }, + coverage: { + primary_requests: 0, + captured_requests: 0, + unavailable_requests: 0, + complete_response_requests: 0, + input_allocated_requests: 0, + output_allocated_requests: 0, + reasoning_measured_requests: 0, + reasoning_missing_requests: 0, + estimator_qualified_models: 0, + estimator_unavailable_models: 0, + diagnostics: {}, + }, + caveats: TOKEN_ACCOUNTING_CAVEATS, + authority_effect: "none", + }; +} + +function harness() { + const stdout = new Set<(bytes: Uint8Array) => void>(); + const stderr = new Set<(bytes: Uint8Array) => void>(); + const errors = new Set<() => void>(); + const closes = new Set<(code: number | null) => void>(); + const deadlines = new Set<() => void>(); + const subscribe = (listeners: Set, listener: A) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + const child: ProcessReaderChild = { + onStdout: (listener) => subscribe(stdout, listener), + onStderr: (listener) => subscribe(stderr, listener), + onError: (listener) => subscribe(errors, listener), + onClose: (listener) => subscribe(closes, listener), + kill: vi.fn(), + destroyOutputs: vi.fn(), + }; + let resolveSpawn!: () => void; + let resolveVerify!: () => void; + let resolveScheduled!: () => void; + const futureSpawns = new Set<() => void>(); + const spawned = new Promise((resolve) => { + resolveSpawn = resolve; + }); + const verifying = new Promise((resolve) => { + resolveVerify = resolve; + }); + const scheduled = new Promise((resolve) => { + resolveScheduled = resolve; + }); + const verify = vi + .spyOn(Config, "verifyProcessReaderConfiguration") + .mockImplementation(async () => { + resolveVerify(); + return binding; + }); + const runtime: ProcessReaderRuntime = { + fileSystem: { + lstat: vi.fn().mockRejectedValue(new Error("unexpected file access")), + open: vi.fn().mockRejectedValue(new Error("unexpected file access")), + }, + identity: vi.fn().mockRejectedValue(new Error("unexpected host observation")), + spawn: vi.fn(() => { + resolveSpawn(); + for (const ready of futureSpawns) ready(); + futureSpawns.clear(); + return child; + }), + deadline: vi.fn((milliseconds: number, callback: () => void) => { + expect(milliseconds).toBe(5000); + resolveScheduled(); + return subscribe(deadlines, callback); + }), + }; + const emit = (listeners: Set<(bytes: Uint8Array) => void>, bytes: Uint8Array) => { + for (const listener of listeners) listener(bytes); + }; + const close = (code: number | null = 0) => { + for (const listener of closes) listener(code); + }; + return { + runtime, + child, + verify, + spawned, + verifying, + scheduled, + nextSpawn: () => + new Promise((resolve) => { + futureSpawns.add(resolve); + }), + reader: () => makeProcessReader(config, runtime), + stdout: (text: string) => emit(stdout, new TextEncoder().encode(text)), + stdoutBytes: (bytes: Uint8Array) => emit(stdout, bytes), + stderr: (bytes: Uint8Array) => emit(stderr, bytes), + close, + error: () => { + for (const listener of errors) listener(); + }, + expire: () => { + for (const callback of deadlines) callback(); + }, + reply: (value: unknown) => { + emit(stdout, new TextEncoder().encode(encodeJson(value))); + close(); + }, + listeners: () => stdout.size + stderr.size + errors.size + closes.size + deadlines.size, + }; +} +const read = (input: ReturnType) => + input.reader().readSummary({ reportId, limits: TOKEN_ACCOUNTING_READER_LIMITS }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it.layer(Layer.succeed(HostProcessPlatform, "linux"))( + "fixed canonical accounting subprocess reader", + (it) => { + it.effect("defaults unconfigured with zero verification, files, timers or subprocesses", () => + Effect.gen(function* () { + const input = harness(); + const reader = makeProcessReader(undefined, input.runtime); + expect(yield* reader.checkBinding).toEqual({ + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }); + expect( + yield* reader.readSummary({ reportId, limits: TOKEN_ACCOUNTING_READER_LIMITS }), + ).toEqual({ status: "unconfigured", reason: "reader_unconfigured" }); + expect(input.verify).not.toHaveBeenCalled(); + expect(input.runtime.spawn).not.toHaveBeenCalled(); + expect(input.runtime.deadline).not.toHaveBeenCalled(); + }), + ); + + it.effect( + "rejects malformed configuration and caller-selected reports or limits before verification", + () => + Effect.gen(function* () { + const input = harness(); + const bad = makeProcessReader( + { ...config, command: "caller-command" } as never, + input.runtime, + ); + expect(yield* bad.checkBinding).toMatchObject({ + status: "unconfigured", + reason: "host_binding_unverified", + }); + expect( + yield* input.reader().readSummary({ + reportId: "c".repeat(64), + limits: TOKEN_ACCOUNTING_READER_LIMITS, + }), + ).toEqual({ status: "invalid", reason: "configured_report_id_mismatch" }); + expect( + yield* input.reader().readSummary({ + reportId, + limits: { ...TOKEN_ACCOUNTING_READER_LIMITS, maxInputBytes: 1 } as never, + }), + ).toEqual({ status: "reader_failed", reason: "reader_failed" }); + expect(input.verify).not.toHaveBeenCalled(); + expect(input.runtime.spawn).not.toHaveBeenCalled(); + }), + ); + + it.effect( + "never spawns after failed custody or digest verification and returns no diagnostics", + () => + Effect.gen(function* () { + const input = harness(); + input.verify.mockRejectedValue(new Error("/synthetic/private/path")); + expect(yield* input.reader().checkBinding).toEqual({ + status: "unconfigured", + reason: "host_binding_unverified", + configuredReportId: reportId, + }); + expect(yield* read(input)).toEqual({ + status: "unconfigured", + reason: "host_binding_unverified", + }); + expect(input.runtime.spawn).not.toHaveBeenCalled(); + expect(input.listeners()).toBe(0); + }), + ); + + it.effect( + "spawns only the pinned interpreter with fixed argv, cwd, empty stdin and sanitized environment", + () => + Effect.gen(function* () { + const input = harness(); + const checked = yield* input.reader().checkBinding.pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + expect(input.verify).toHaveBeenCalledWith( + config, + input.runtime.fileSystem, + expect.any(Function), + expect.any(AbortSignal), + ); + vi.mocked(input.runtime.identity).mockResolvedValue({ + realUid: 42, + effectiveUid: 42, + savedUid: 42, + }); + const identify = vi.mocked(input.verify).mock.calls[0]![2]; + const signal = new AbortController().signal; + yield* Effect.promise(() => identify(signal)); + expect(input.runtime.identity).toHaveBeenCalledExactlyOnceWith(signal, "linux"); + expect(input.runtime.spawn).toHaveBeenCalledExactlyOnceWith( + binding.python.path, + [ + "-I", + "-B", + binding.helper.path, + "--binding", + config.bindingPath, + "--binding-sha256", + config.bindingSha256, + "check", + "--report-id", + reportId, + ], + { + cwd: binding.source_root, + env: { LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + input.reply({ status: "bound", configuredReportId: reportId }); + expect(yield* Fiber.join(checked)).toEqual({ + status: "bound", + configuredReportId: reportId, + }); + expect(input.child.kill).not.toHaveBeenCalled(); + expect(input.listeners()).toBe(0); + }), + ); + + it.effect( + "rejects bound reply identity mismatch and newer fields before advertising availability", + () => + Effect.gen(function* () { + for (const reply of [ + { status: "bound", configuredReportId: "d".repeat(64) }, + { status: "bound", configuredReportId: reportId, verified: true }, + { status: "unconfigured", reason: "reader_failed", configuredReportId: reportId }, + ]) { + const input = harness(); + const checked = yield* input.reader().checkBinding.pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + input.reply(reply); + expect(yield* Fiber.join(checked)).toEqual({ + status: "unconfigured", + reason: "host_binding_unverified", + configuredReportId: reportId, + }); + vi.restoreAllMocks(); + } + }), + ); + + it.effect("preserves typed unconfigured check replies including a null configured report", () => + Effect.gen(function* () { + const input = harness(); + const checked = yield* input.reader().checkBinding.pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + const reply = { + status: "unconfigured", + reason: "report_unconfigured", + configuredReportId: null, + }; + input.reply(reply); + expect(yield* Fiber.join(checked)).toEqual(reply); + }), + ); + + it.effect("reverifies pins for each read and returns the direct closed projection text", () => + Effect.gen(function* () { + const input = harness(); + const reader = input.reader(); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + input.reply(report()); + expect(yield* Fiber.join(response)).toBe(encodeJson(report())); + expect(input.runtime.spawn).toHaveBeenCalledWith( + binding.python.path, + expect.arrayContaining(["read", "--report-id", reportId]), + expect.anything(), + ); + const spawnedAgain = input.nextSpawn(); + const next = yield* reader + .readSummary({ reportId, limits: TOKEN_ACCOUNTING_READER_LIMITS }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => spawnedAgain); + input.reply(report()); + expect(yield* Fiber.join(next)).toBe(encodeJson(report())); + expect(input.verify).toHaveBeenCalledTimes(2); + }), + ); + + it.effect( + "preserves closed unavailable statuses and rejects newer failure or projection fields", + () => + Effect.gen(function* () { + const cases = [ + [ + { status: "missing", reason: "configured_report_missing" }, + { status: "missing", reason: "configured_report_missing" }, + ], + [ + { status: "oversized", reason: "input_too_large" }, + { status: "oversized", reason: "input_too_large" }, + ], + [ + { status: "unsupported", reason: "report_schema_unsupported" }, + { status: "unsupported", reason: "report_schema_unsupported" }, + ], + [ + { status: "invalid", reason: "report_identity_mismatch" }, + { status: "invalid", reason: "report_identity_mismatch" }, + ], + [ + { status: "missing", reason: "configured_report_missing", path: "/private" }, + { status: "invalid", reason: "projection_invalid" }, + ], + [ + { ...report(), report_id: "d".repeat(64) }, + { status: "invalid", reason: "configured_report_id_mismatch" }, + ], + [ + { ...report(), future_metadata: {} }, + { status: "invalid", reason: "projection_invalid" }, + ], + ]; + for (const [reply, expected] of cases) { + const input = harness(); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + input.reply(reply); + expect(yield* Fiber.join(response)).toEqual(expected); + expect(input.listeners()).toBe(0); + vi.restoreAllMocks(); + } + }), + ); + + it.effect("rejects invalid UTF-8 and malformed JSON without diagnostic text", () => + Effect.gen(function* () { + for (const bytes of [ + new Uint8Array([0xff]), + new TextEncoder().encode("invalid JSON /private"), + ]) { + const input = harness(); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + input.stdoutBytes(bytes); + input.close(); + expect(yield* Fiber.join(response)).toEqual({ + status: "invalid", + reason: "projection_invalid", + }); + expect(input.listeners()).toBe(0); + vi.restoreAllMocks(); + } + }), + ); + + it.effect("caps raw stdout chunks before concatenation and kills only the captured child", () => + Effect.gen(function* () { + const input = harness(); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + input.stdoutBytes(new Uint8Array(TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES)); + expect(input.child.kill).not.toHaveBeenCalled(); + input.stdoutBytes(new Uint8Array(1)); + expect(yield* Fiber.join(response)).toEqual({ + status: "oversized", + reason: "projection_too_large", + }); + expect(input.child.kill).toHaveBeenCalledTimes(1); + expect(input.child.destroyOutputs).toHaveBeenCalledTimes(1); + expect(input.listeners()).toBe(0); + }), + ); + + it.effect("accepts the exact stdout cap and discards bounded stderr without returning it", () => + Effect.gen(function* () { + const input = harness(); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + const text = encodeJson({ status: "missing", reason: "configured_report_missing" }); + input.stdout(text + " ".repeat(TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES - text.length)); + input.stderr(new TextEncoder().encode("/synthetic/private/stderr")); + input.close(); + expect(yield* Fiber.join(response)).toEqual({ + status: "missing", + reason: "configured_report_missing", + }); + expect(input.child.kill).not.toHaveBeenCalled(); + expect(input.listeners()).toBe(0); + }), + ); + + it.effect("fails closed on excessive stderr, child errors, nonzero exit and spawn errors", () => + Effect.gen(function* () { + for (const failure of ["stderr", "error", "exit", "spawn"] as const) { + const input = harness(); + if (failure === "spawn") + vi.mocked(input.runtime.spawn).mockImplementation(() => { + throw new Error("/private executable"); + }); + const response = yield* read(input).pipe(Effect.forkChild); + if (failure !== "spawn") { + yield* Effect.promise(() => input.spawned); + if (failure === "stderr") input.stderr(new Uint8Array(16 * 1024 + 1)); + if (failure === "error") input.error(); + if (failure === "exit") input.close(1); + } + expect(yield* Fiber.join(response)).toEqual({ + status: "reader_failed", + reason: "reader_failed", + }); + expect(input.child.kill).toHaveBeenCalledTimes( + failure === "stderr" || failure === "error" ? 1 : 0, + ); + expect(input.listeners()).toBe(0); + vi.restoreAllMocks(); + } + }), + ); + + it.effect( + "includes pin verification in the deadline and never spawns after late verification", + () => + Effect.gen(function* () { + const input = harness(); + let resolve!: (value: Config.ProcessReaderBinding) => void; + const pending = new Promise((ready) => { + resolve = ready; + }); + input.verify.mockImplementation(() => pending); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.scheduled); + expect(input.runtime.deadline).toHaveBeenCalledTimes(1); + input.expire(); + expect(yield* Fiber.join(response)).toEqual({ + status: "reader_failed", + reason: "reader_timeout", + }); + resolve(binding); + yield* Effect.promise(() => pending); + yield* Effect.yieldNow; + expect(input.runtime.spawn).not.toHaveBeenCalled(); + expect(input.listeners()).toBe(0); + }), + ); + + it.effect("kills and removes all waiters on a child deadline", () => + Effect.gen(function* () { + const input = harness(); + const response = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + input.expire(); + expect(yield* Fiber.join(response)).toEqual({ + status: "reader_failed", + reason: "reader_timeout", + }); + expect(input.child.kill).toHaveBeenCalledTimes(1); + expect(input.listeners()).toBe(0); + }), + ); + + it.effect("cancels only its own captured child and cleans all callbacks on interruption", () => + Effect.gen(function* () { + const input = harness(); + const fiber = yield* read(input).pipe(Effect.forkChild); + yield* Effect.promise(() => input.spawned); + yield* Fiber.interrupt(fiber); + expect(input.child.kill).toHaveBeenCalledTimes(1); + expect(input.child.destroyOutputs).toHaveBeenCalledTimes(1); + expect(input.listeners()).toBe(0); + }), + ); + }, +); diff --git a/apps/server/src/tokenAccounting/ProcessReader.ts b/apps/server/src/tokenAccounting/ProcessReader.ts new file mode 100644 index 000000000..46e6ca2c0 --- /dev/null +++ b/apps/server/src/tokenAccounting/ProcessReader.ts @@ -0,0 +1,332 @@ +// Native spawn captures only this adapter's child; the narrow runtime is injectable for synthetic checks. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeTimers from "node:timers"; + +import { + TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + TokenAccountingReport, + TokenAccountingUnavailable, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + ProcessReaderConfiguration, + processReaderFileSystem, + processReaderIdentity, + verifyProcessReaderConfiguration, + type ProcessReaderFileSystem, + type ProcessReaderIdentity, +} from "./ProcessReaderConfig.ts"; +import { + TOKEN_ACCOUNTING_READER_LIMITS, + TokenAccountingReaderBinding, + unconfiguredReader, + type TokenAccountingReaderPort, +} from "./Reader.ts"; + +export interface ProcessReaderChild { + readonly onStdout: (listener: (bytes: Uint8Array) => void) => () => void; + readonly onStderr: (listener: (bytes: Uint8Array) => void) => () => void; + readonly onError: (listener: () => void) => () => void; + readonly onClose: (listener: (code: number | null) => void) => () => void; + readonly kill: () => void; + readonly destroyOutputs: () => void; +} +export interface ProcessReaderSpawnOptions { + readonly cwd: string; + readonly env: Readonly>; + readonly shell: false; + readonly windowsHide: true; + readonly stdio: readonly ["ignore", "pipe", "pipe"]; +} +export interface ProcessReaderRuntime { + readonly fileSystem: ProcessReaderFileSystem; + readonly identity: ( + signal: AbortSignal, + platform: NodeJS.Platform, + ) => Promise; + readonly spawn: ( + executable: string, + args: readonly string[], + options: ProcessReaderSpawnOptions, + ) => ProcessReaderChild; + readonly deadline: (milliseconds: number, callback: () => void) => () => void; +} + +const nativeRuntime: ProcessReaderRuntime = { + fileSystem: processReaderFileSystem, + identity: processReaderIdentity, + spawn: (executable, args, options) => { + const child = NodeChildProcess.spawn(executable, args, { + ...options, + stdio: ["ignore", "pipe", "pipe"], + }); + return { + onStdout: (listener) => { + child.stdout.on("data", listener); + return () => { + child.stdout.removeListener("data", listener); + }; + }, + onStderr: (listener) => { + child.stderr.on("data", listener); + return () => { + child.stderr.removeListener("data", listener); + }; + }, + onError: (listener) => { + child.once("error", listener); + child.stdout.once("error", listener); + child.stderr.once("error", listener); + return () => { + child.removeListener("error", listener); + child.stdout.removeListener("error", listener); + child.stderr.removeListener("error", listener); + }; + }, + onClose: (listener) => { + child.once("close", listener); + return () => { + child.removeListener("close", listener); + }; + }, + kill: () => { + child.kill("SIGKILL"); + }, + destroyOutputs: () => { + child.stdout.destroy(); + child.stderr.destroy(); + }, + }; + }, + deadline: (milliseconds, callback) => { + // This native callback deadline covers verification and shares the captured-child cleanup seam. + // @effect-diagnostics-next-line globalTimers:off + const timer = NodeTimers.setTimeout(callback, milliseconds); + timer.unref(); + return () => { + NodeTimers.clearTimeout(timer); + }; + }, +}; + +const decodeConfiguration = Schema.decodeUnknownSync(ProcessReaderConfiguration); +const decodeBinding = Schema.decodeUnknownSync(Schema.fromJsonString(TokenAccountingReaderBinding)); +const decodeUnavailable = Schema.decodeUnknownSync( + Schema.fromJsonString(TokenAccountingUnavailable), +); +const decodeReport = Schema.decodeUnknownSync(Schema.fromJsonString(TokenAccountingReport)); +const bindingFailure = (reportId: string | null): TokenAccountingReaderBinding => ({ + status: "unconfigured", + reason: "host_binding_unverified", + configuredReportId: reportId, +}); +const readerFailed: TokenAccountingUnavailable = { + status: "reader_failed", + reason: "reader_failed", +}; +const hostUnverified: TokenAccountingUnavailable = { + status: "unconfigured", + reason: "host_binding_unverified", +}; +const projectionInvalid: TokenAccountingUnavailable = { + status: "invalid", + reason: "projection_invalid", +}; +const DEADLINE_MILLISECONDS = 5000; +const MAX_STDERR_BYTES = 16 * 1024; + +function decodeReadResponse(text: string, reportId: string): string | TokenAccountingUnavailable { + try { + return decodeUnavailable(text); + } catch { + try { + const report = decodeReport(text); + return report.report_id === reportId + ? text + : { status: "invalid", reason: "configured_report_id_mismatch" }; + } catch { + return projectionInvalid; + } + } +} + +/** Only an explicitly enrolled server configuration can construct a subprocess reader. */ +export function makeProcessReader( + configuration?: ProcessReaderConfiguration, + runtime: ProcessReaderRuntime = nativeRuntime, +): TokenAccountingReaderPort { + if (configuration === undefined) return unconfiguredReader; + let config: ProcessReaderConfiguration; + try { + config = Object.freeze(decodeConfiguration(configuration)); + } catch { + return { + checkBinding: Effect.succeed(bindingFailure(null)), + readSummary: () => Effect.succeed(hostUnverified), + }; + } + + const run = (operation: "check" | "read") => + HostProcessPlatform.pipe( + Effect.flatMap((platform) => + Effect.callback((resume) => { + const abort = new AbortController(); + let child: ProcessReaderChild | undefined; + let childClosed = false; + let finished = false; + let cleaned = false; + let cancelDeadline = () => {}; + const listeners: Array<() => void> = []; + const chunks: Uint8Array[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + abort.abort(); + cancelDeadline(); + if (child !== undefined) { + if (!childClosed) { + try { + child.kill(); + } catch { + /* The captured child may already have exited. */ + } + } + child.destroyOutputs(); + } + for (const remove of listeners.splice(0)) remove(); + chunks.length = 0; + }; + const finish = (value: string | TokenAccountingUnavailable) => { + if (finished) return; + finished = true; + cleanup(); + resume(Effect.succeed(value)); + }; + cancelDeadline = runtime.deadline(DEADLINE_MILLISECONDS, () => + finish({ status: "reader_failed", reason: "reader_timeout" }), + ); + void verifyProcessReaderConfiguration( + config, + runtime.fileSystem, + (signal) => runtime.identity(signal, platform), + abort.signal, + ).then( + (binding) => { + if (finished || abort.signal.aborted) return; + try { + child = runtime.spawn( + binding.python.path, + [ + "-I", + "-B", + binding.helper.path, + "--binding", + config.bindingPath, + "--binding-sha256", + config.bindingSha256, + operation, + "--report-id", + config.reportId, + ], + { + cwd: binding.source_root, + env: { LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + listeners.push( + child.onStdout((bytes) => { + if (finished || bytes.byteLength === 0) return; + stdoutBytes += bytes.byteLength; + if (stdoutBytes > TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES) { + finish({ status: "oversized", reason: "projection_too_large" }); + return; + } + chunks.push(bytes.slice()); + }), + ); + listeners.push( + child.onStderr((bytes) => { + stderrBytes += bytes.byteLength; + if (stderrBytes > MAX_STDERR_BYTES) finish(readerFailed); + }), + ); + listeners.push(child.onError(() => finish(readerFailed))); + listeners.push( + child.onClose((code) => { + childClosed = true; + if (code !== 0) { + finish(readerFailed); + return; + } + try { + const bytes = new Uint8Array(stdoutBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + finish(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + finish(projectionInvalid); + } + }), + ); + } catch { + finish(readerFailed); + } + }, + () => finish(hostUnverified), + ); + return Effect.sync(() => { + finished = true; + cleanup(); + }); + }), + ), + ); + + return { + checkBinding: run("check").pipe( + Effect.map((response) => { + if (typeof response !== "string") return bindingFailure(config.reportId); + try { + const checked = decodeBinding(response); + return checked.configuredReportId === config.reportId || + (checked.status === "unconfigured" && checked.configuredReportId === null) + ? checked + : bindingFailure(config.reportId); + } catch { + return bindingFailure(config.reportId); + } + }), + ), + readSummary: ({ reportId, limits }) => { + if (reportId !== config.reportId) { + return Effect.succeed({ + status: "invalid", + reason: "configured_report_id_mismatch", + } as const); + } + if ( + Object.entries(TOKEN_ACCOUNTING_READER_LIMITS).some( + ([key, value]) => limits[key as keyof typeof limits] !== value, + ) + ) + return Effect.succeed(readerFailed); + return run("read").pipe( + Effect.map((response) => + typeof response === "string" ? decodeReadResponse(response, config.reportId) : response, + ), + ); + }, + }; +} diff --git a/apps/server/src/tokenAccounting/ProcessReaderConfig.test.ts b/apps/server/src/tokenAccounting/ProcessReaderConfig.test.ts new file mode 100644 index 000000000..2caa0e96f --- /dev/null +++ b/apps/server/src/tokenAccounting/ProcessReaderConfig.test.ts @@ -0,0 +1,293 @@ +// All filesystem entries below are in-memory; these checks never enroll or inspect a host. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + ProcessReaderBinding, + ProcessReaderConfiguration, + TOKEN_ACCOUNTING_SOURCE_PATHS, + verifyProcessReaderConfiguration, + type ProcessReaderFileSystem, + type ProcessReaderStat, +} from "./ProcessReaderConfig.ts"; + +const uid = 42; +const reportId = "a".repeat(64); +const hash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); +const encode = (value: string) => new TextEncoder().encode(value); +const stat = (directory = false, size = 0): ProcessReaderStat => ({ + size, + uid, + mode: directory ? 0o40755 : 0o100600, + nlink: 1, + dev: 1, + ino: 1, + isFile: () => !directory, + isDirectory: () => directory, + isSymbolicLink: () => false, +}); + +function fixture() { + const sourceRoot = "/fixture/src"; + const files = new Map(); + const closure = Object.fromEntries( + TOKEN_ACCOUNTING_SOURCE_PATHS.map((path) => { + files.set(`${sourceRoot}/${path}`, encode(path)); + return [path, hash(path)]; + }), + ); + files.set("/fixture/python", encode("pinned interpreter")); + files.set("/etc/machine-id", encode(`${"b".repeat(32)}\n`)); + const binding = { + schema: "programmatic-token-info.saved-accounting-binding/v1", + machine_id_sha256: hash("b".repeat(32)), + uid, + python: { path: "/fixture/python", sha256: hash("pinned interpreter") }, + helper: { + path: `${sourceRoot}/codex_v3/token_info/saved_reader.py`, + sha256: closure["codex_v3/token_info/saved_reader.py"], + }, + source_root: sourceRoot, + source_closure: closure, + source_closure_sha256: hash(JSON.stringify(closure)), + archive_root: "/fixture/archive", + report_id: reportId, + authority_effect: "none", + }; + const opened: string[] = []; + const closed: string[] = []; + const stats = new Map(); + const fileSystem: ProcessReaderFileSystem = { + lstat: async (path) => stats.get(path) ?? stat(!files.has(path), files.get(path)?.byteLength), + open: async (path) => { + opened.push(path); + const bytes = files.get(path); + if (bytes === undefined) throw new Error("synthetic missing file"); + return { + stat: async () => stats.get(path) ?? stat(false, bytes.byteLength), + read: async (buffer, position) => { + const chunk = bytes.subarray(position, position + buffer.byteLength); + buffer.set(chunk); + return chunk.byteLength; + }, + close: async () => { + closed.push(path); + }, + }; + }, + }; + const enroll = () => { + const json = JSON.stringify(binding); + files.set("/fixture/binding.json", encode(json)); + return { bindingPath: "/fixture/binding.json", bindingSha256: hash(json), reportId }; + }; + return { + binding, + files, + stats, + opened, + closed, + enroll, + verify: () => + verifyProcessReaderConfiguration( + enroll(), + fileSystem, + async () => ({ realUid: uid, effectiveUid: uid, savedUid: uid }), + new AbortController().signal, + ), + fileSystem, + }; +} + +describe("fixed process reader configuration", () => { + it("rejects caller keys, non-normalized paths and malformed pins", () => { + const decode = Schema.decodeUnknownSync(ProcessReaderConfiguration); + const valid = fixture().enroll(); + expect(decode(valid)).toEqual(valid); + for (const invalid of [ + { ...valid, extra: "ignored" }, + { ...valid, bindingPath: "relative" }, + { ...valid, bindingPath: "/fixture/../binding.json" }, + { ...valid, bindingPath: "/fixture//binding.json" }, + { ...valid, bindingPath: "/fixture/binding.json/" }, + { ...valid, bindingPath: "/fixture/binding.json\0" }, + { ...valid, reportId: "A".repeat(64) }, + { ...valid, bindingSha256: "not-a-hash" }, + ]) + expect(() => decode(invalid)).toThrow(); + }); + + it("accepts only the closed fifteen-file binding closure", () => { + const decode = Schema.decodeUnknownSync(ProcessReaderBinding); + const { binding } = fixture(); + expect(decode(binding).source_closure).toEqual(binding.source_closure); + const { [TOKEN_ACCOUNTING_SOURCE_PATHS[0]]: _first, ...missing } = binding.source_closure; + for (const invalid of [ + { ...binding, metadata: "newer field" }, + { ...binding, python: { ...binding.python, flags: [] } }, + { ...binding, source_closure: missing }, + { ...binding, source_closure: { ...binding.source_closure, "other.py": reportId } }, + { ...binding, uid: 0 }, + ]) + expect(() => decode(invalid)).toThrow(); + }); + + it("verifies all pins without opening the archive, report, index or raw sources", async () => { + const input = fixture(); + const verified = await input.verify(); + expect(verified.report_id).toBe(reportId); + expect(input.opened).toEqual([ + "/fixture/binding.json", + "/etc/machine-id", + "/fixture/python", + ...TOKEN_ACCOUNTING_SOURCE_PATHS.map((path) => `/fixture/src/${path}`), + ]); + expect(input.closed).toEqual(input.opened); + }); + + it("rejects descriptor digest mismatch before reading interpreter or source pins", async () => { + const input = fixture(); + const config = { ...input.enroll(), bindingSha256: "c".repeat(64) }; + await expect( + verifyProcessReaderConfiguration( + config, + input.fileSystem, + async () => ({ realUid: uid, effectiveUid: uid, savedUid: uid }), + new AbortController().signal, + ), + ).rejects.toThrow(); + expect(input.opened).toEqual(["/fixture/binding.json"]); + expect(input.closed).toEqual(input.opened); + }); + + it("rejects mismatched machine, UID, helper, closure and report bindings", async () => { + for (const mutate of [ + (input: ReturnType) => { + input.binding.machine_id_sha256 = reportId; + }, + (input: ReturnType) => { + input.binding.uid = uid + 1; + }, + (input: ReturnType) => { + input.binding.helper.path = "/fixture/foreign.py"; + }, + (input: ReturnType) => { + input.binding.helper.sha256 = reportId; + }, + (input: ReturnType) => { + input.binding.source_closure_sha256 = reportId; + }, + (input: ReturnType) => { + input.binding.report_id = "d".repeat(64); + }, + ]) { + const input = fixture(); + mutate(input); + await expect(input.verify()).rejects.toThrow(); + expect(input.opened).not.toContain("/fixture/python"); + expect(input.closed).toEqual(input.opened); + } + }); + + it("rejects elevated, unequal and saved UID identities before any file opens", async () => { + for (const identity of [ + { realUid: 0, effectiveUid: 0, savedUid: 0 }, + { realUid: uid, effectiveUid: uid + 1, savedUid: uid }, + { realUid: uid, effectiveUid: uid, savedUid: 0 }, + ]) { + const input = fixture(); + await expect( + verifyProcessReaderConfiguration( + input.enroll(), + input.fileSystem, + async () => identity, + new AbortController().signal, + ), + ).rejects.toThrow(); + expect(input.opened).toEqual([]); + } + }); + + it("rejects symlink ancestors, writable custody, public bindings and multiple links", async () => { + for (const [path, replacement] of [ + ["/fixture", { ...stat(true), isSymbolicLink: () => true }], + ["/fixture", { ...stat(true), mode: 0o40777 }], + ["/fixture/binding.json", { ...stat(), mode: 0o100640 }], + ["/fixture/binding.json", { ...stat(), uid: 0 }], + ["/fixture/binding.json", { ...stat(), nlink: 2 }], + ] as const) { + const input = fixture(); + input.stats.set(path, replacement); + await expect(input.verify()).rejects.toThrow(); + expect(input.opened).toEqual([]); + } + }); + + it("rejects changed interpreter and closure bytes and closes every opened descriptor", async () => { + for (const path of [ + "/fixture/python", + "/fixture/src/codex_v3/token_info/accounting_report.py", + ]) { + const input = fixture(); + input.files.set(path, encode("changed bytes")); + await expect(input.verify()).rejects.toThrow(); + expect(input.closed).toEqual(input.opened); + expect(input.opened).toContain(path); + } + }); + + it("bounds the descriptor before parsing and rejects replacement after lstat", async () => { + for (const kind of ["oversized", "replaced"] as const) { + const input = fixture(); + const config = input.enroll(); + if (kind === "oversized") input.files.set(config.bindingPath, new Uint8Array(64 * 1024 + 1)); + const fs: ProcessReaderFileSystem = + kind === "oversized" + ? input.fileSystem + : { + ...input.fileSystem, + open: async (path) => { + const file = await input.fileSystem.open(path); + return { ...file, stat: async () => ({ ...(await file.stat()), ino: 2 }) }; + }, + }; + await expect( + verifyProcessReaderConfiguration( + config, + fs, + async () => ({ realUid: uid, effectiveUid: uid, savedUid: uid }), + new AbortController().signal, + ), + ).rejects.toThrow(); + expect(input.opened).toEqual([config.bindingPath]); + expect(input.closed).toEqual(input.opened); + } + }); + + it("closes a descriptor that resolves after cancellation without reading it", async () => { + const input = fixture(); + const config = input.enroll(); + const abort = new AbortController(); + const fs: ProcessReaderFileSystem = { + ...input.fileSystem, + open: async (path) => { + const file = await input.fileSystem.open(path); + abort.abort(); + return file; + }, + }; + await expect( + verifyProcessReaderConfiguration( + config, + fs, + async () => ({ realUid: uid, effectiveUid: uid, savedUid: uid }), + abort.signal, + ), + ).rejects.toThrow(); + expect(input.opened).toEqual([config.bindingPath]); + expect(input.closed).toEqual(input.opened); + }); +}); diff --git a/apps/server/src/tokenAccounting/ProcessReaderConfig.ts b/apps/server/src/tokenAccounting/ProcessReaderConfig.ts new file mode 100644 index 000000000..cb81a39c7 --- /dev/null +++ b/apps/server/src/tokenAccounting/ProcessReaderConfig.ts @@ -0,0 +1,345 @@ +// The enrolled subprocess boundary needs descriptor-level no-follow opens and byte hashing. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import * as Schema from "effect/Schema"; + +// Inspect input keys before Struct decoding removes undeclared properties. +const closed = (schema: Schema.Struct) => + Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter( + (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Reflect.ownKeys(value).every((key) => Object.hasOwn(schema.fields, key)), + ), + ), + ); +const sha256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const absolutePath = Schema.String.check( + Schema.isMaxLength(4096), + Schema.makeFilter( + (value) => + !value.endsWith("/") && + !value.includes("\0") && + NodePath.posix.isAbsolute(value) && + NodePath.posix.normalize(value) === value, + ), +); + +export const TOKEN_ACCOUNTING_SOURCE_PATHS = [ + "codex_v3/__init__.py", + "codex_v3/cache_keepalive/__init__.py", + "codex_v3/cache_keepalive/contracts.py", + "codex_v3/cache_keepalive/costing.py", + "codex_v3/token_info/__init__.py", + "codex_v3/token_info/accounting_contracts.py", + "codex_v3/token_info/accounting_report.py", + "codex_v3/token_info/attribution_contracts.py", + "codex_v3/token_info/attribution_report.py", + "codex_v3/token_info/contracts.py", + "codex_v3/token_info/query.py", + "codex_v3/token_info/report.py", + "codex_v3/token_info/saved_reader.py", + "codex_v3/token_info/saved_reader_contracts.py", + "codex_v3/token_info/saved_reader_projection.py", +] as const; +const sourceClosure = closed( + Schema.Struct( + Object.fromEntries(TOKEN_ACCOUNTING_SOURCE_PATHS.map((path) => [path, sha256])) as Record< + (typeof TOKEN_ACCOUNTING_SOURCE_PATHS)[number], + typeof sha256 + >, + ), +); +const filePin = closed(Schema.Struct({ path: absolutePath, sha256 })); + +export const ProcessReaderConfiguration = closed( + Schema.Struct({ + bindingPath: absolutePath, + bindingSha256: sha256, + reportId: sha256, + }), +); +export type ProcessReaderConfiguration = typeof ProcessReaderConfiguration.Type; + +export const ProcessReaderBinding = closed( + Schema.Struct({ + schema: Schema.Literal("programmatic-token-info.saved-accounting-binding/v1"), + machine_id_sha256: sha256, + uid: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })), + python: filePin, + helper: filePin, + source_root: absolutePath, + source_closure: sourceClosure, + source_closure_sha256: sha256, + archive_root: absolutePath, + report_id: sha256, + authority_effect: Schema.Literal("none"), + }), +); +export type ProcessReaderBinding = typeof ProcessReaderBinding.Type; + +export type ProcessReaderStat = Pick< + NodeFS.Stats, + "size" | "uid" | "mode" | "nlink" | "dev" | "ino" | "isFile" | "isDirectory" | "isSymbolicLink" +>; +export interface ProcessReaderFile { + readonly stat: () => Promise; + readonly read: (buffer: Uint8Array, position: number) => Promise; + readonly close: () => Promise; +} +export interface ProcessReaderFileSystem { + readonly lstat: (path: string) => Promise; + readonly open: (path: string) => Promise; +} +export interface ProcessReaderIdentity { + readonly realUid: number; + readonly effectiveUid: number; + readonly savedUid: number; +} + +export const processReaderFileSystem: ProcessReaderFileSystem = { + lstat: (path) => NodeFSP.lstat(path), + open: async (path) => { + const handle = await NodeFSP.open( + path, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK, + ); + return { + stat: () => handle.stat(), + read: async (buffer, position) => + (await handle.read(buffer, 0, buffer.byteLength, position)).bytesRead, + close: () => handle.close(), + }; + }, +}; + +const assertActive = (signal: AbortSignal) => { + if (signal.aborted) throw new Error("reader_cancelled"); +}; +const hashBytes = (bytes: Uint8Array) => + NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +const utf8 = new TextDecoder("utf-8", { fatal: true }); +const MAX_BINDING_BYTES = 64 * 1024; +const MAX_PIN_BYTES = 128 * 1024 * 1024; +const CHUNK_BYTES = 64 * 1024; + +async function readFileBytes( + fileSystem: ProcessReaderFileSystem, + path: string, + maximum: number, + signal: AbortSignal, + expected?: ProcessReaderStat, +): Promise { + assertActive(signal); + const file = await fileSystem.open(path); + try { + assertActive(signal); + const stat = await file.stat(); + if (!stat.isFile() || stat.size > maximum) throw new Error("binding_unverified"); + if ( + expected !== undefined && + (stat.dev !== expected.dev || + stat.ino !== expected.ino || + stat.uid !== expected.uid || + stat.nlink !== expected.nlink || + stat.mode !== expected.mode) + ) { + throw new Error("binding_unverified"); + } + const buffer = new Uint8Array(Math.min(CHUNK_BYTES, maximum + 1)); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + assertActive(signal); + const count = await file.read(buffer, bytes); + assertActive(signal); + if (count === 0) break; + bytes += count; + if (bytes > maximum) throw new Error("binding_unverified"); + chunks.push(buffer.slice(0, count)); + } + const result = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; + } finally { + await file.close(); + } +} + +export async function processReaderIdentity( + signal: AbortSignal, + platform: NodeJS.Platform, +): Promise { + if (platform !== "linux" || process.getuid === undefined || process.geteuid === undefined) { + throw new Error("binding_unverified"); + } + const status = utf8.decode( + await readFileBytes(processReaderFileSystem, "/proc/self/status", MAX_BINDING_BYTES, signal), + ); + const uids = /^Uid:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$/m.exec(status); + if (uids === null) throw new Error("binding_unverified"); + const realUid = process.getuid(); + const effectiveUid = process.geteuid(); + if (Number(uids[1]) !== realUid || Number(uids[2]) !== effectiveUid) + throw new Error("binding_unverified"); + return { realUid, effectiveUid, savedUid: Number(uids[3]) }; +} + +function assertCustody(stat: ProcessReaderStat, uid: number, directory: boolean): void { + if ( + stat.isSymbolicLink() || + (directory ? !stat.isDirectory() : !stat.isFile()) || + (stat.uid !== 0 && stat.uid !== uid) + ) + throw new Error("binding_unverified"); + const stickyRootDirectory = directory && stat.uid === 0 && (stat.mode & 0o1000) !== 0; + if ((stat.mode & 0o022) !== 0 && !stickyRootDirectory) throw new Error("binding_unverified"); +} + +async function verifyPath( + fileSystem: ProcessReaderFileSystem, + path: string, + uid: number, + directory: boolean, + signal: AbortSignal, +): Promise { + const components = path.split("/").filter(Boolean); + const parents = [ + "/", + ...components + .slice(0, -1) + .map((_part, index) => `/${components.slice(0, index + 1).join("/")}`), + ]; + for (const parent of parents) { + assertActive(signal); + assertCustody(await fileSystem.lstat(parent), uid, true); + } + assertActive(signal); + const stat = await fileSystem.lstat(path); + assertActive(signal); + assertCustody(stat, uid, directory); + return stat; +} + +async function verifyFilePin( + fileSystem: ProcessReaderFileSystem, + path: string, + digest: string, + uid: number, + signal: AbortSignal, +): Promise { + const expected = await verifyPath(fileSystem, path, uid, false, signal); + const file = await fileSystem.open(path); + try { + assertActive(signal); + const stat = await file.stat(); + assertCustody(stat, uid, false); + if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.size > MAX_PIN_BYTES) { + throw new Error("binding_unverified"); + } + const hash = NodeCrypto.createHash("sha256"); + const buffer = new Uint8Array(CHUNK_BYTES); + let bytes = 0; + while (true) { + assertActive(signal); + const count = await file.read(buffer, bytes); + assertActive(signal); + if (count === 0) break; + bytes += count; + if (bytes > MAX_PIN_BYTES) throw new Error("binding_unverified"); + hash.update(buffer.subarray(0, count)); + } + if (hash.digest("hex") !== digest) throw new Error("binding_unverified"); + } finally { + await file.close(); + } +} + +const decodeBinding = Schema.decodeUnknownSync(Schema.fromJsonString(ProcessReaderBinding)); + +export async function verifyProcessReaderConfiguration( + config: ProcessReaderConfiguration, + fileSystem: ProcessReaderFileSystem, + identity: (signal: AbortSignal) => Promise, + signal: AbortSignal, +): Promise { + const uids = await identity(signal); + assertActive(signal); + if ( + !Number.isSafeInteger(uids.realUid) || + uids.realUid <= 0 || + uids.realUid !== uids.effectiveUid || + uids.realUid !== uids.savedUid + ) { + throw new Error("binding_unverified"); + } + const bindingStat = await verifyPath(fileSystem, config.bindingPath, uids.realUid, false, signal); + if ( + bindingStat.uid !== uids.realUid || + (bindingStat.mode & 0o077) !== 0 || + bindingStat.nlink !== 1 + ) { + throw new Error("binding_unverified"); + } + const bytes = await readFileBytes( + fileSystem, + config.bindingPath, + MAX_BINDING_BYTES, + signal, + bindingStat, + ); + if (hashBytes(bytes) !== config.bindingSha256) throw new Error("binding_unverified"); + const binding = decodeBinding(utf8.decode(bytes)); + if ( + binding.uid !== uids.realUid || + binding.report_id !== config.reportId || + binding.helper.path !== `${binding.source_root}/codex_v3/token_info/saved_reader.py` || + binding.helper.sha256 !== binding.source_closure["codex_v3/token_info/saved_reader.py"] + ) { + throw new Error("binding_unverified"); + } + const closureJson = JSON.stringify( + Object.fromEntries( + Object.entries(binding.source_closure).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ), + ); + if (hashBytes(new TextEncoder().encode(closureJson)) !== binding.source_closure_sha256) { + throw new Error("binding_unverified"); + } + const machineBytes = await readFileBytes(fileSystem, "/etc/machine-id", 1024, signal); + if (machineBytes.some((byte) => byte > 127)) throw new Error("binding_unverified"); + const machineId = utf8.decode(machineBytes).replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, ""); + if ( + !/^[0-9a-f]{32}$/.test(machineId) || + hashBytes(new TextEncoder().encode(machineId)) !== binding.machine_id_sha256 + ) { + throw new Error("binding_unverified"); + } + await verifyPath(fileSystem, binding.source_root, binding.uid, true, signal); + await verifyPath(fileSystem, binding.archive_root, binding.uid, true, signal); + await verifyFilePin(fileSystem, binding.python.path, binding.python.sha256, binding.uid, signal); + for (const relativePath of TOKEN_ACCOUNTING_SOURCE_PATHS) { + await verifyFilePin( + fileSystem, + `${binding.source_root}/${relativePath}`, + binding.source_closure[relativePath], + binding.uid, + signal, + ); + } + assertActive(signal); + return binding; +} diff --git a/apps/server/src/tokenAccounting/Reader.ts b/apps/server/src/tokenAccounting/Reader.ts new file mode 100644 index 000000000..022f7353b --- /dev/null +++ b/apps/server/src/tokenAccounting/Reader.ts @@ -0,0 +1,79 @@ +import { + TOKEN_ACCOUNTING_MAX_GROUPS, + TOKEN_ACCOUNTING_MAX_INPUT_BYTES, + TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION, + TokenAccountingReportId, + TokenAccountingUnavailable, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const closed = (schema: Schema.Struct) => + Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter( + (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Reflect.ownKeys(value).every((key) => Object.hasOwn(schema.fields, key)), + ), + ), + ); + +export const TokenAccountingReaderBinding = Schema.Union([ + closed( + Schema.Struct({ + status: Schema.Literal("bound"), + configuredReportId: TokenAccountingReportId, + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("unconfigured"), + reason: Schema.Literals([ + "reader_unconfigured", + "report_unconfigured", + "host_binding_unverified", + ]), + configuredReportId: Schema.NullOr(TokenAccountingReportId), + }), + ), +]); +export type TokenAccountingReaderBinding = typeof TokenAccountingReaderBinding.Type; + +export const TOKEN_ACCOUNTING_READER_LIMITS = Object.freeze({ + maxInputBytes: TOKEN_ACCOUNTING_MAX_INPUT_BYTES, + maxResponseBytes: TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + maxGroups: TOKEN_ACCOUNTING_MAX_GROUPS, + maxSourceCollection: TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION, +} as const); + +export class TokenAccountingReaderError extends Schema.TaggedError()( + "TokenAccountingReaderError", + { cause: Schema.Defect() }, +) {} + +export interface TokenAccountingReaderPort { + /** The owning adapter verifies enrollment and custody here without accessing the archive. */ + readonly checkBinding: Effect.Effect; + /** + * The adapter rechecks custody before reading only the configured report, enforces the + * input limit before parsing, and runs Python's canonical validator before projecting. + * This is an injected port, not a launcher or a host-attestation format. + */ + readonly readSummary: (request: { + readonly reportId: string; + readonly limits: typeof TOKEN_ACCOUNTING_READER_LIMITS; + }) => Effect.Effect; +} + +export const unconfiguredReader: TokenAccountingReaderPort = { + checkBinding: Effect.succeed({ + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }), + readSummary: () => Effect.succeed({ status: "unconfigured", reason: "reader_unconfigured" }), +}; diff --git a/apps/server/src/tokenAccounting/RuntimeReader.test.ts b/apps/server/src/tokenAccounting/RuntimeReader.test.ts new file mode 100644 index 000000000..dbeaef8fc --- /dev/null +++ b/apps/server/src/tokenAccounting/RuntimeReader.test.ts @@ -0,0 +1,242 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { expect, it, vi } from "@effect/vitest"; + +import { makeProcessReader, type ProcessReaderRuntime } from "./ProcessReader.ts"; +import type { ProcessReaderConfiguration, ProcessReaderStat } from "./ProcessReaderConfig.ts"; +import { + TOKEN_ACCOUNTING_READER_LIMITS, + unconfiguredReader, + type TokenAccountingReaderPort, +} from "./Reader.ts"; +import { makeRuntimeReader } from "./RuntimeReader.ts"; + +const reportId = "a".repeat(64); +const bindingSha256 = "b".repeat(64); +const bindingPath = "/fixture/binding.json"; +const environment = { + T3_TOKEN_ACCOUNTING_BINDING_PATH: bindingPath, + T3_TOKEN_ACCOUNTING_BINDING_SHA256: bindingSha256, + T3_TOKEN_ACCOUNTING_REPORT_ID: reportId, +}; +const request = { reportId, limits: TOKEN_ACCOUNTING_READER_LIMITS }; +const unavailable = { status: "unconfigured", reason: "host_binding_unverified" } as const; + +function runtime(): ProcessReaderRuntime { + const stat = (directory: boolean): ProcessReaderStat => ({ + size: 0, + uid: 42, + mode: directory ? 0o40755 : 0o100600, + nlink: 1, + dev: 1, + ino: 1, + isFile: () => !directory, + isDirectory: () => directory, + isSymbolicLink: () => false, + }); + return { + fileSystem: { + lstat: vi.fn(async (path: string) => stat(path !== bindingPath)), + open: vi.fn().mockRejectedValue(new Error("synthetic descriptor unavailable")), + }, + identity: vi.fn(async () => ({ realUid: 42, effectiveUid: 42, savedUid: 42 })), + spawn: vi.fn(() => { + throw new Error("unexpected subprocess"); + }), + deadline: vi.fn(() => () => {}), + }; +} + +it.layer(Layer.succeed(HostProcessPlatform, "linux"))("saved accounting startup reader", (it) => { + it.effect("returns the existing unconfigured port when all three keys are absent", () => + Effect.gen(function* () { + const factory = vi.fn(() => unconfiguredReader); + const reader = makeRuntimeReader({}, factory); + expect(reader).toBe(unconfiguredReader); + expect(yield* reader.checkBinding).toEqual({ + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }); + expect(yield* reader.readSummary(request)).toEqual({ + status: "unconfigured", + reason: "reader_unconfigured", + }); + expect(factory).not.toHaveBeenCalled(); + }), + ); + + it.effect( + "reads only the three startup keys and preserves their bytes in the injected constructor", + () => + Effect.gen(function* () { + const observed: PropertyKey[] = []; + const allowed = new Set(Object.keys(environment)); + const input = new Proxy>>( + { + ...environment, + PATH: "/ignored", + PYTHONPATH: "/ignored", + T3_TOKEN_ACCOUNTING_ARCHIVE_PATH: "/ignored", + }, + { + get: (target, key) => { + expect(typeof key === "string" && allowed.has(key)).toBe(true); + observed.push(key); + return target[key as string]; + }, + ownKeys: () => { + throw new Error("environment must not be enumerated"); + }, + }, + ); + const missing = { status: "missing", reason: "configured_report_missing" } as const; + const injected: TokenAccountingReaderPort = { + checkBinding: Effect.succeed({ status: "bound", configuredReportId: reportId }), + readSummary: () => Effect.succeed(missing), + }; + const factory = vi.fn(() => injected); + const reader = makeRuntimeReader(input, factory); + expect(observed).toEqual(Object.keys(environment)); + expect(factory).toHaveBeenCalledExactlyOnceWith({ bindingPath, bindingSha256, reportId }); + expect(reader).toBe(injected); + expect(yield* reader.checkBinding).toEqual({ + status: "bound", + configuredReportId: reportId, + }); + expect(yield* reader.readSummary(request)).toEqual(missing); + }), + ); + + it.effect( + "keeps every partial configuration unavailable without constructing a process reader", + () => + Effect.gen(function* () { + for (const input of [ + { T3_TOKEN_ACCOUNTING_BINDING_PATH: bindingPath }, + { T3_TOKEN_ACCOUNTING_BINDING_SHA256: bindingSha256 }, + { T3_TOKEN_ACCOUNTING_REPORT_ID: reportId }, + { + T3_TOKEN_ACCOUNTING_BINDING_PATH: bindingPath, + T3_TOKEN_ACCOUNTING_BINDING_SHA256: bindingSha256, + }, + { + T3_TOKEN_ACCOUNTING_BINDING_PATH: bindingPath, + T3_TOKEN_ACCOUNTING_REPORT_ID: reportId, + }, + { + T3_TOKEN_ACCOUNTING_BINDING_SHA256: bindingSha256, + T3_TOKEN_ACCOUNTING_REPORT_ID: reportId, + }, + ]) { + const factory = vi.fn(() => unconfiguredReader); + const reader = makeRuntimeReader(input, factory); + expect(yield* reader.checkBinding).toEqual({ + ...unavailable, + configuredReportId: "T3_TOKEN_ACCOUNTING_REPORT_ID" in input ? reportId : null, + }); + expect(yield* reader.readSummary(request)).toEqual(unavailable); + expect(factory).not.toHaveBeenCalled(); + } + }), + ); + + it.effect( + "rejects malformed paths or pins without trimming, normalizing, throwing or opening anything", + () => + Effect.gen(function* () { + for (const input of [ + { ...environment, T3_TOKEN_ACCOUNTING_BINDING_PATH: "relative.json" }, + { ...environment, T3_TOKEN_ACCOUNTING_BINDING_PATH: "/fixture/../binding.json" }, + { ...environment, T3_TOKEN_ACCOUNTING_BINDING_PATH: "/fixture/binding.json\0" }, + { ...environment, T3_TOKEN_ACCOUNTING_BINDING_SHA256: "" }, + { ...environment, T3_TOKEN_ACCOUNTING_BINDING_SHA256: bindingSha256 + "\n" }, + { ...environment, T3_TOKEN_ACCOUNTING_BINDING_SHA256: "B".repeat(64) }, + { ...environment, T3_TOKEN_ACCOUNTING_REPORT_ID: "A".repeat(64) }, + { ...environment, T3_TOKEN_ACCOUNTING_REPORT_ID: reportId + "\n" }, + { ...environment, T3_TOKEN_ACCOUNTING_REPORT_ID: "not-a-report-id" }, + { + T3_TOKEN_ACCOUNTING_BINDING_PATH: "", + T3_TOKEN_ACCOUNTING_BINDING_SHA256: "", + T3_TOKEN_ACCOUNTING_REPORT_ID: "", + }, + ]) { + const native = runtime(); + const factory = vi.fn((configuration: ProcessReaderConfiguration) => + makeProcessReader(configuration, native), + ); + const reader = makeRuntimeReader(input, factory); + expect(yield* reader.checkBinding).toEqual({ + ...unavailable, + configuredReportId: input.T3_TOKEN_ACCOUNTING_REPORT_ID === reportId ? reportId : null, + }); + expect(yield* reader.readSummary(request)).toEqual(unavailable); + expect(factory).not.toHaveBeenCalled(); + expect(native.identity).not.toHaveBeenCalled(); + expect(native.fileSystem.lstat).not.toHaveBeenCalled(); + expect(native.fileSystem.open).not.toHaveBeenCalled(); + expect(native.spawn).not.toHaveBeenCalled(); + expect(native.deadline).not.toHaveBeenCalled(); + } + }), + ); + + it.effect("constructs the configured process port with zero host or archive I/O", () => + Effect.sync(() => { + const native = runtime(); + const factory = vi.fn((configuration: ProcessReaderConfiguration) => + makeProcessReader(configuration, native), + ); + const reader = makeRuntimeReader(environment, factory); + expect(factory).toHaveBeenCalledExactlyOnceWith({ bindingPath, bindingSha256, reportId }); + expect(reader).not.toBe(unconfiguredReader); + expect(native.identity).not.toHaveBeenCalled(); + expect(native.fileSystem.lstat).not.toHaveBeenCalled(); + expect(native.fileSystem.open).not.toHaveBeenCalled(); + expect(native.spawn).not.toHaveBeenCalled(); + expect(native.deadline).not.toHaveBeenCalled(); + }), + ); + + it.effect("keeps caller report and path fields from selecting a different enrolled target", () => + Effect.gen(function* () { + const native = runtime(); + const reader = makeRuntimeReader(environment, (configuration) => + makeProcessReader(configuration, native), + ); + const callerPath = "/caller/report.json"; + expect( + yield* reader.readSummary({ + ...request, + reportId: "c".repeat(64), + path: callerPath, + } as never), + ).toEqual({ status: "invalid", reason: "configured_report_id_mismatch" }); + expect(native.identity).not.toHaveBeenCalled(); + expect(native.fileSystem.open).not.toHaveBeenCalled(); + expect(yield* reader.readSummary({ ...request, path: callerPath } as never)).toEqual( + unavailable, + ); + expect(native.fileSystem.open).toHaveBeenCalledExactlyOnceWith(bindingPath); + expect(native.spawn).not.toHaveBeenCalled(); + }), + ); + + it.effect( + "keeps a failed constructor local without exposing its exception or breaking startup", + () => + Effect.gen(function* () { + const factory = vi.fn(() => { + throw new Error("/synthetic/private/constructor"); + }); + const reader = makeRuntimeReader(environment, factory); + expect(yield* reader.checkBinding).toEqual({ + ...unavailable, + configuredReportId: reportId, + }); + expect(yield* reader.readSummary(request)).toEqual(unavailable); + expect(factory).toHaveBeenCalledTimes(1); + }), + ); +}); diff --git a/apps/server/src/tokenAccounting/RuntimeReader.ts b/apps/server/src/tokenAccounting/RuntimeReader.ts new file mode 100644 index 000000000..530ebe4aa --- /dev/null +++ b/apps/server/src/tokenAccounting/RuntimeReader.ts @@ -0,0 +1,53 @@ +import { TokenAccountingReportId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { makeProcessReader } from "./ProcessReader.ts"; +import { ProcessReaderConfiguration } from "./ProcessReaderConfig.ts"; +import { unconfiguredReader, type TokenAccountingReaderPort } from "./Reader.ts"; + +const decodeConfiguration = Schema.decodeUnknownSync(ProcessReaderConfiguration); +const decodeReportId = Schema.decodeUnknownSync(TokenAccountingReportId); + +function unverifiedReader(reportId: string | undefined): TokenAccountingReaderPort { + let configuredReportId: string | null = null; + try { + configuredReportId = reportId?.length === 64 ? decodeReportId(reportId) : null; + } catch { + configuredReportId = null; + } + return { + checkBinding: Effect.succeed({ + status: "unconfigured", + reason: "host_binding_unverified", + configuredReportId, + }), + readSummary: () => + Effect.succeed({ status: "unconfigured", reason: "host_binding_unverified" }), + }; +} + +/** Startup configuration constructs a reader; its checkBinding separately verifies enrollment. */ +export function makeRuntimeReader( + environment: Readonly>, + readerFactory: ( + configuration: ProcessReaderConfiguration, + ) => TokenAccountingReaderPort = makeProcessReader, +): TokenAccountingReaderPort { + const bindingPath = environment.T3_TOKEN_ACCOUNTING_BINDING_PATH; + const bindingSha256 = environment.T3_TOKEN_ACCOUNTING_BINDING_SHA256; + const reportId = environment.T3_TOKEN_ACCOUNTING_REPORT_ID; + if (bindingPath === undefined && bindingSha256 === undefined && reportId === undefined) { + return unconfiguredReader; + } + try { + const configuration = decodeConfiguration({ bindingPath, bindingSha256, reportId }); + // Startup pins must remain exactly 64 characters independently of schema decoding. + if (configuration.bindingSha256.length !== 64 || configuration.reportId.length !== 64) { + return unverifiedReader(reportId); + } + return readerFactory(Object.freeze(configuration)); + } catch { + return unverifiedReader(reportId); + } +} diff --git a/apps/server/src/tokenAccounting/TokenAccountingRpc.test.ts b/apps/server/src/tokenAccounting/TokenAccountingRpc.test.ts new file mode 100644 index 000000000..957db3e23 --- /dev/null +++ b/apps/server/src/tokenAccounting/TokenAccountingRpc.test.ts @@ -0,0 +1,71 @@ +import { + AuthAccessWriteScope, + AuthOrchestrationReadScope, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as RpcTest from "effect/unstable/rpc/RpcTest"; + +import { RPC_REQUIRED_SCOPES, rpcScopeAuthorizationLayer } from "../auth/RpcAuthorization.ts"; +import * as TokenAccountingService from "./TokenAccountingService.ts"; + +const method = WS_METHODS.serverReadTokenAccounting; +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => tag !== method, + ), +); +const handler = group.toLayerHandler(method, () => + Effect.flatMap(TokenAccountingService.TokenAccountingService, (service) => service.read), +); + +describe("saved accounting RPC", () => { + it.effect("returns the unconfigured result without enrolling a reader", () => + Effect.gen(function* () { + const service = yield* TokenAccountingService.TokenAccountingService; + expect(yield* service.isAvailable).toBe(false); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.mergeAll(handler, rpcScopeAuthorizationLayer([AuthOrchestrationReadScope]))), + ); + expect(yield* client[method]({})).toMatchObject({ + state: "unavailable", + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }); + }).pipe(Effect.provide(TokenAccountingService.layer), Effect.scoped), + ); + + it.effect("rejects insufficient scope before invoking the reader", () => + Effect.gen(function* () { + let reads = 0; + const service = Layer.succeed( + TokenAccountingService.TokenAccountingService, + TokenAccountingService.TokenAccountingService.of({ + isAvailable: Effect.succeed(true), + read: Effect.sync(() => { + reads += 1; + return { + state: "unavailable" as const, + status: "unconfigured" as const, + reason: "reader_unconfigured" as const, + configuredReportId: null, + readAt: "2026-10-04T00:00:00.000Z", + }; + }), + }), + ); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.mergeAll(handler, rpcScopeAuthorizationLayer([AuthAccessWriteScope])).pipe(Layer.provide(service))), + ); + expect(yield* client[method]({}).pipe(Effect.flip)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + }); + expect(reads).toBe(0); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/tokenAccounting/TokenAccountingService.test.ts b/apps/server/src/tokenAccounting/TokenAccountingService.test.ts new file mode 100644 index 000000000..076b922a8 --- /dev/null +++ b/apps/server/src/tokenAccounting/TokenAccountingService.test.ts @@ -0,0 +1,400 @@ +import { + TOKEN_ACCOUNTING_CAVEATS, + TOKEN_ACCOUNTING_IDENTITY_ALGORITHM, + TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + TOKEN_ACCOUNTING_PROJECTION_SCHEMA, + TOKEN_ACCOUNTING_REPORT_SCHEMA, + type TokenAccountingMetric, + type TokenAccountingReport, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { + TOKEN_ACCOUNTING_READER_LIMITS, + TokenAccountingReaderError, + type TokenAccountingReaderPort, +} from "./Reader.ts"; +import { make } from "./TokenAccountingService.ts"; + +const REPORT_ID = "a".repeat(64); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const metric = (requests = 1): TokenAccountingMetric => ({ + known_sum: null, + total: null, + known_requests: 0, + missing_requests: requests, +}); +const metrics = (requests = 1) => ({ + input_tokens: metric(requests), + cached_input_tokens: metric(requests), + cache_write_input_tokens: metric(requests), + cache_write_5m_tokens: metric(requests), + cache_write_1h_tokens: metric(requests), + output_tokens: metric(requests), + reasoning_output_tokens: metric(requests), +}); +const partition = (requests: number) => ({ + requests, + metrics: { ...metrics(requests), ordinary_input: metric(requests) }, +}); +const statusCounts = { primary: 1, legacy_unresolved: 0, conflict: 0, aggregate_delta: 0 }; + +// Synthetic projection identities exercise transport only; Python owns complete-source identity proof. +function report(): TokenAccountingReport { + const allocation = { + allocated: metric(), + unknown: metric(), + groups: [], + unknown_by_reason: { missing_counter: metric() }, + estimated_coverage: null, + }; + return { + schema: TOKEN_ACCOUNTING_PROJECTION_SCHEMA, + source_schema: TOKEN_ACCOUNTING_REPORT_SCHEMA, + source_identity_algorithm: TOKEN_ACCOUNTING_IDENTITY_ALGORITHM, + source_identity_verified: true, + report_id: REPORT_ID, + selection_id: "b".repeat(64), + snapshot: { index_snapshot_id: "c".repeat(64), captured_at: "2026-10-02T12:00:00Z" }, + window: { start: "2026-09-01T00:00:00Z", end: "2026-09-02T00:00:00Z", end_exclusive: true }, + provider_coverage: { + selected_requests: 1, + accounting_status_counts: statusCounts, + token_missingness: { + input_tokens: 1, + cached_input_tokens: 1, + cache_write_input_tokens: 1, + cache_write_5m_tokens: 1, + cache_write_1h_tokens: 1, + output_tokens: 1, + reasoning_output_tokens: 1, + }, + latest_scan: { + captured_at: null, + selected_files: null, + refreshed_selected_files: null, + partial_selected_files: null, + root_scope_ids: [], + mtime_cutoff: null, + receipts_status_counts: null, + validated_files: null, + }, + cumulative_index: { + tracked_files: null, + retained_not_refreshed_files: null, + missing_tracked_files: null, + }, + freshness: { + status: "scan_unavailable", + requested_end: null, + last_scan_captured_at: null, + selected_validation_min: null, + selected_validation_max: null, + unrefreshed_files: null, + }, + historical_window_completeness: "not_proven", + }, + provider_ledger: { + requests: 1, + primary_requests: 1, + nonadditive_requests: 0, + accounting_status_counts: statusCounts, + metrics: { ...metrics(), ordinary_input_tokens: metric(), non_read_input_tokens: metric() }, + }, + input: { ordinary_input: metric(), ...allocation }, + output: { output: metric(), measured_reasoning: metric(), ...allocation }, + partitions: { + provider: { codex: partition(1), claude: partition(0) }, + role: { root: partition(1), child: partition(0), unknown: partition(0) }, + }, + mechanism_flags: { nonadditive: true, counts: {} }, + estimator_status_counts: { qualified: 0, unavailable: 0, failed: 0 }, + coverage: { + primary_requests: 1, + captured_requests: 0, + unavailable_requests: 1, + complete_response_requests: 0, + input_allocated_requests: 0, + output_allocated_requests: 0, + reasoning_measured_requests: 0, + reasoning_missing_requests: 1, + estimator_qualified_models: 0, + estimator_unavailable_models: 0, + diagnostics: {}, + }, + caveats: TOKEN_ACCOUNTING_CAVEATS, + authority_effect: "none", + }; +} + +const reader = ( + readSummary: TokenAccountingReaderPort["readSummary"] = () => + Effect.succeed(encodeJson(report())), +): TokenAccountingReaderPort => ({ + checkBinding: Effect.succeed({ status: "bound", configuredReportId: REPORT_ID }), + readSummary, +}); + +describe("saved accounting transport", () => { + it.effect("defaults unavailable without enrolling or reading a report", () => + Effect.scoped( + Effect.gen(function* () { + const service = yield* make(); + expect(yield* service.isAvailable).toBe(false); + expect(yield* service.read).toMatchObject({ + state: "unavailable", + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }); + }), + ), + ); + + it.effect("does not dispatch an archive read for unconfigured or invalid bindings", () => + Effect.scoped( + Effect.gen(function* () { + for (const checkBinding of [ + Effect.succeed({ + status: "unconfigured", + reason: "host_binding_unverified", + configuredReportId: REPORT_ID, + } as const), + Effect.succeed({ status: "bound", configuredReportId: "not-a-sha" } as never), + ]) { + let reads = 0; + const service = yield* make({ + checkBinding, + readSummary: () => + Effect.sync(() => { + reads += 1; + return encodeJson(report()); + }), + }); + expect(yield* service.isAvailable).toBe(false); + expect(yield* service.read).toMatchObject({ + state: "unavailable", + status: "unconfigured", + reason: "host_binding_unverified", + }); + expect(reads).toBe(0); + } + }), + ), + ); + + it.effect( + "preserves nulls, identities and window separately from the server observation time", + () => + Effect.scoped( + Effect.gen(function* () { + let request: Parameters[0] | undefined; + const expected = report(); + const service = yield* make( + reader((input) => + Effect.sync(() => { + request = input; + return encodeJson(expected); + }), + ), + ); + const readAt = DateTime.formatIso(yield* DateTime.now); + expect(yield* service.isAvailable).toBe(true); + expect(yield* service.read).toEqual({ state: "ready", readAt, report: expected }); + expect(request).toEqual({ reportId: REPORT_ID, limits: TOKEN_ACCOUNTING_READER_LIMITS }); + expect(readAt).not.toBe(expected.snapshot.captured_at); + }), + ), + ); + + it.effect("rejects configured identity mismatch and undeclared projection data", () => + Effect.scoped( + Effect.gen(function* () { + const mismatch = yield* make( + reader(() => Effect.succeed(encodeJson({ ...report(), report_id: "d".repeat(64) }))), + ); + expect(yield* mismatch.read).toMatchObject({ + state: "unavailable", + status: "invalid", + reason: "configured_report_id_mismatch", + }); + const extra = yield* make( + reader(() => Effect.succeed(encodeJson({ ...report(), visible_inventory: {} }))), + ); + expect(yield* extra.read).toMatchObject({ + state: "unavailable", + status: "invalid", + reason: "projection_invalid", + }); + }), + ), + ); + + it.effect("rejects excessive groups and root identities without truncating them", () => + Effect.scoped( + Effect.gen(function* () { + const source = report(); + const cases = [ + { + ...source, + input: { + ...source.input, + groups: Array.from({ length: 129 }, () => ({ + group: "guidance", + subtype: "system", + basis: "measured_component", + central: 0, + low: 0, + high: 0, + })), + }, + }, + { + ...source, + provider_coverage: { + ...source.provider_coverage, + latest_scan: { + ...source.provider_coverage.latest_scan, + root_scope_ids: Array.from({ length: 257 }, () => REPORT_ID), + }, + }, + }, + ]; + for (const value of cases) { + const service = yield* make(reader(() => Effect.succeed(encodeJson(value)))); + expect(yield* service.read).toMatchObject({ + state: "unavailable", + status: "oversized", + reason: "collection_limit_exceeded", + }); + } + }), + ), + ); + + it.effect("rejects estimator status counts that disagree with source coverage", () => + Effect.scoped( + Effect.gen(function* () { + const service = yield* make( + reader(() => + Effect.succeed( + encodeJson({ + ...report(), + estimator_status_counts: { qualified: 0, unavailable: 1, failed: 0 }, + }), + ), + ), + ); + expect(yield* service.read).toMatchObject({ + state: "unavailable", + status: "invalid", + reason: "projection_invalid", + }); + }), + ), + ); + + it.effect("enforces the response byte cap before parsing and includes the result envelope", () => + Effect.scoped( + Effect.gen(function* () { + const tooLarge = yield* make( + reader(() => Effect.succeed("é".repeat(TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES / 2 + 1))), + ); + expect(yield* tooLarge.read).toMatchObject({ + state: "unavailable", + status: "oversized", + reason: "projection_too_large", + }); + const payload = encodeJson(report()); + const exactInputLimit = + payload + " ".repeat(TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES - payload.length); + const envelope = yield* make(reader(() => Effect.succeed(exactInputLimit))); + expect(yield* envelope.read).toMatchObject({ state: "ready" }); + expect(encodeJson(yield* envelope.read).length).toBeLessThanOrEqual( + TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + ); + }), + ), + ); + + it.effect("keeps reader failures local and omits exception details", () => + Effect.scoped( + Effect.gen(function* () { + const service = yield* make( + reader(() => + Effect.fail(new TokenAccountingReaderError({ cause: "synthetic-private-path" })), + ), + ); + const result = yield* service.read; + expect(result).toMatchObject({ + state: "unavailable", + status: "reader_failed", + reason: "reader_failed", + }); + expect(encodeJson(result)).not.toContain("synthetic-private-path"); + const unavailable = yield* make( + reader(() => Effect.succeed({ status: "missing", reason: "configured_report_missing" })), + ); + expect(yield* unavailable.read).toMatchObject({ + state: "unavailable", + status: "missing", + reason: "configured_report_missing", + }); + }), + ), + ); + + it.effect("shares an overlapping read and starts a new observation after completion", () => + Effect.scoped( + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + const service = yield* make( + reader(() => + Effect.gen(function* () { + reads += 1; + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + return encodeJson(report()); + }), + ), + ); + const first = yield* service.read.pipe(Effect.forkChild); + yield* Deferred.await(entered); + const second = yield* service.read.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toEqual(yield* Fiber.join(second)); + expect(reads).toBe(1); + yield* service.read; + expect(reads).toBe(2); + }), + ), + ); + + it.effect("bounds a stalled reader by the five-second deadline", () => + Effect.scoped( + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const service = yield* make( + reader(() => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never))), + ); + const pending = yield* service.read.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("5 seconds"); + expect(yield* Fiber.join(pending)).toMatchObject({ + state: "unavailable", + status: "reader_failed", + reason: "reader_timeout", + }); + }), + ), + ); +}); diff --git a/apps/server/src/tokenAccounting/TokenAccountingService.ts b/apps/server/src/tokenAccounting/TokenAccountingService.ts new file mode 100644 index 000000000..f184b9fd7 --- /dev/null +++ b/apps/server/src/tokenAccounting/TokenAccountingService.ts @@ -0,0 +1,168 @@ +import { + TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES, + TOKEN_ACCOUNTING_MAX_GROUPS, + TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION, + TokenAccountingReadResult, + TokenAccountingReport, + TokenAccountingUnavailable, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { + TOKEN_ACCOUNTING_READER_LIMITS, + TokenAccountingReaderBinding, + type TokenAccountingReaderPort, + unconfiguredReader, +} from "./Reader.ts"; + +export class TokenAccountingService extends Context.Service< + TokenAccountingService, + { + readonly isAvailable: Effect.Effect; + readonly read: Effect.Effect; + } +>()("t3/tokenAccounting/TokenAccountingService") {} + +const decodeReport = Schema.decodeUnknownEffect(TokenAccountingReport); +const decodeUnavailable = Schema.decodeUnknownEffect(TokenAccountingUnavailable); +const decodeBinding = Schema.decodeUnknownEffect(TokenAccountingReaderBinding); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeResult = Schema.encodeEffect(Schema.fromJsonString(TokenAccountingReadResult)); +const utf8Bytes = (value: string) => new TextEncoder().encode(value).byteLength; +const field = (value: unknown, key: string): unknown => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record)[key] + : undefined; +function hasOversizedCollection(value: unknown): boolean { + return [ + [field(field(value, "input"), "groups"), TOKEN_ACCOUNTING_MAX_GROUPS], + [field(field(value, "output"), "groups"), TOKEN_ACCOUNTING_MAX_GROUPS], + [ + field(field(field(value, "provider_coverage"), "latest_scan"), "root_scope_ids"), + TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION, + ], + ].some( + ([collection, limit]) => Array.isArray(collection) && collection.length > (limit as number), + ); +} + +export const make = Effect.fn("makeTokenAccountingService")(function* ( + reader: TokenAccountingReaderPort = unconfiguredReader, +) { + const scope = yield* Scope.Scope; + let pending: Deferred.Deferred | undefined; + + const binding = reader.checkBinding.pipe(Effect.flatMap(decodeBinding)); + const isAvailable = binding.pipe( + Effect.map((value) => value.status === "bound"), + Effect.catch(() => Effect.succeed(false)), + Effect.catchDefect(() => Effect.succeed(false)), + Effect.timeoutOption("5 seconds"), + Effect.map((value) => Option.getOrElse(value, () => false)), + ); + + const readOnce = Effect.gen(function* () { + const readAt = DateTime.formatIso(yield* DateTime.now); + let configuredReportId: string | null = null; + const unavailable = (failure: TokenAccountingUnavailable): TokenAccountingReadResult => ({ + state: "unavailable", + ...failure, + configuredReportId, + readAt, + }); + + const observe = Effect.gen(function* () { + const selected = yield* binding.pipe(Effect.option); + if (Option.isNone(selected)) { + return unavailable({ status: "unconfigured", reason: "host_binding_unverified" }); + } + configuredReportId = selected.value.configuredReportId; + if (selected.value.status !== "bound") + return unavailable({ + status: selected.value.status, + reason: selected.value.reason, + }); + + const response = yield* reader.readSummary({ + reportId: selected.value.configuredReportId, + limits: TOKEN_ACCOUNTING_READER_LIMITS, + }); + if (typeof response !== "string") { + const failure = yield* decodeUnavailable(response); + return unavailable(failure); + } + if (utf8Bytes(response) > TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES) { + return unavailable({ status: "oversized", reason: "projection_too_large" }); + } + const parsed = yield* decodeJson(response).pipe(Effect.option); + if (Option.isNone(parsed)) { + return unavailable({ status: "invalid", reason: "projection_invalid" }); + } + if (hasOversizedCollection(parsed.value)) { + return unavailable({ status: "oversized", reason: "collection_limit_exceeded" }); + } + const decoded = yield* decodeReport(parsed.value).pipe(Effect.option); + if (Option.isNone(decoded)) { + return unavailable({ status: "invalid", reason: "projection_invalid" }); + } + if (decoded.value.report_id !== selected.value.configuredReportId) { + return unavailable({ status: "invalid", reason: "configured_report_id_mismatch" }); + } + const result: TokenAccountingReadResult = { + state: "ready", + readAt, + report: decoded.value, + }; + if (utf8Bytes(yield* encodeResult(result)) > TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES) { + return unavailable({ status: "oversized", reason: "projection_too_large" }); + } + return result; + }).pipe( + Effect.catch(() => + Effect.succeed(unavailable({ status: "reader_failed", reason: "reader_failed" })), + ), + Effect.catchDefect(() => + Effect.succeed(unavailable({ status: "reader_failed", reason: "reader_failed" })), + ), + Effect.timeoutOption("5 seconds"), + ); + return Option.getOrElse(yield* observe, () => + unavailable({ status: "reader_failed", reason: "reader_timeout" }), + ); + }); + + const read = Effect.gen(function* () { + const flight = yield* Effect.uninterruptible( + Effect.gen(function* () { + if (pending !== undefined) return pending; + const created = Deferred.makeUnsafe(); + pending = created; + // The server scope owns this read, so one disconnected waiter cannot cancel other clients. + yield* readOnce.pipe( + Effect.onExit((exit) => + Effect.sync(() => { + pending = undefined; + }).pipe(Effect.andThen(Deferred.done(created, exit))), + ), + Effect.forkIn(scope), + ); + return created; + }), + ); + return yield* Deferred.await(flight); + }); + + return TokenAccountingService.of({ isAvailable, read }); +}); + +/** Runtime enrollment is owned by the host adapter; this default layer performs no archive reads. */ +export const layer = Layer.effect(TokenAccountingService, make()); +export const layerWithReader = (reader: TokenAccountingReaderPort) => + Layer.effect(TokenAccountingService, make(reader)); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 62345d847..7cb275b5c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -37,10 +37,15 @@ const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unkno const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + timestamp = "2026-08-01T10:00:00Z", +): string { return `${JSON.stringify({ type: "assistant", - timestamp: "2026-08-01T10:00:00Z", + timestamp, requestId: `req_${id}`, sessionId: "session-1", message: { @@ -158,6 +163,74 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("honors exact time bounds for a multi-day daily usage window", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(home, "claude", "projects", "proj", "session.jsonl"), + [ + claudeLine(1, 5, "claude-fable-5", "2026-08-01T09:59:59.999Z"), + claudeLine(2, 7, "claude-fable-5", "2026-08-01T10:00:00.000Z"), + claudeLine(3, 11, "claude-fable-5", "2026-08-02T09:59:59.999Z"), + claudeLine(4, 17, "claude-fable-5", "2026-08-02T21:59:59.999Z"), + claudeLine(5, 13, "claude-fable-5", "2026-08-02T22:00:00.000Z"), + ].join(""), + ), + ); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-exact-daily-window", home, settings }), + ), + ); + const summary = yield* service.readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + resolution: "exactDay", + sinceTime: "2026-08-01T10:00:00.000Z", + untilTime: "2026-08-02T22:00:00.000Z", + }); + + assert.strictEqual(totalOutputTokens(summary), 35); + assert.deepEqual( + summary.buckets.map((bucket) => [bucket.day, bucket.hourStart]), + [ + ["2026-08-01", undefined], + ["2026-08-02", undefined], + ], + ); + }).pipe(Effect.scoped), + ); + + it.live("rejects exact usage windows whose end does not follow their start", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-invalid-exact-window", home, settings }), + ), + ); + const reason = yield* service + .readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + resolution: "exactDay", + sinceTime: "2026-08-02T10:00:00.000Z", + untilTime: "2026-08-01T10:00:00.000Z", + }) + .pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => null, + }), + ); + + assert.strictEqual(reason, "invalidWindow"); + }).pipe(Effect.scoped), + ); + it.live.each([ { explicitDefault: true, label: "explicit" }, { explicitDefault: false, label: "legacy" }, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index ca56c3602..33b656477 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -765,8 +765,8 @@ export const make = Effect.gen(function* () { }); } - let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; - if (input.resolution === "hour") { + let exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.resolution === "hour" || input.resolution === "exactDay") { const sinceTime = input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); const untilTime = @@ -774,19 +774,25 @@ export const make = Effect.gen(function* () { if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: "Hourly usage requires valid sinceTime and untilTime instants", + detail: "An exact usage window requires valid sinceTime and untilTime instants", }); } const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); const untilTimeMs = DateTime.toEpochMillis(untilTime.value); const durationMs = untilTimeMs - sinceTimeMs; - if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { + if (durationMs <= 0) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: "Hourly usage window must be greater than zero and at most 24 hours", + detail: "An exact usage window must end after it starts", }); } - hourlyWindow = { sinceTimeMs, untilTimeMs }; + if (input.resolution === "hour" && durationMs > MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage window must be at most 24 hours", + }); + } + exactWindow = { sinceTimeMs, untilTimeMs }; } const startedAtMs = yield* Clock.currentTimeMillis; @@ -801,7 +807,7 @@ export const make = Effect.gen(function* () { }); } const windowStartMs = - (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; @@ -818,7 +824,7 @@ export const make = Effect.gen(function* () { sinceDay: input.sinceDay, untilDay: input.untilDay, resolution: input.resolution ?? "day", - ...hourlyWindow, + ...exactWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), modelAliases: resolveModelAliases(settings.usageModelAliases), diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index a27a9b101..af08138eb 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -198,6 +198,32 @@ describe("UsageAggregator", () => { ]); }); + it("filters exact daily windows by an inclusive start and exclusive end", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-06", + untilDay: "2026-08-07", + resolution: "exactDay", + sinceTimeMs: Date.parse("2026-08-06T12:00:00.000Z"), + untilTimeMs: Date.parse("2026-08-07T12:00:00.000Z"), + rates, + }); + const result = [ + record({ timestampMs: Date.parse("2026-08-06T11:59:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-06T12:00:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-07T11:59:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-07T12:00:00.000Z") }), + ].map((item) => aggregator.add(item)); + const summary = aggregator.finish(); + + expect(result).toEqual([false, true, true, false]); + expect(summary.outOfWindow).toBe(2); + expect(summary.buckets.map((bucket) => [bucket.day, bucket.hourStart])).toEqual([ + ["2026-08-06", undefined], + ["2026-08-07", undefined], + ]); + }); + it("keeps daily payloads collapsed when hourly resolution is not requested", () => { const result = aggregate([ record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index f2923871a..8c5d008d1 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -137,6 +137,7 @@ export class UsageAggregator { readonly #buckets = new Map(); readonly #seen = new Set(); readonly #toDay: (timestampMs: number) => string; + readonly #exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #lastBucket: { @@ -153,17 +154,25 @@ export class UsageAggregator { constructor(options: AggregateOptions) { this.#options = options; this.#toDay = makeDayFormatter(options.timeZone); - if (options.resolution === "hour") { - if (options.sinceTimeMs === undefined || options.untilTimeMs === undefined) { - throw new Error("Hourly usage aggregation requires exact time bounds"); - } - this.#hourlyWindow = { - sinceTimeMs: options.sinceTimeMs, - untilTimeMs: options.untilTimeMs, - }; - } else { - this.#hourlyWindow = null; + const hasSinceTime = options.sinceTimeMs !== undefined; + const hasUntilTime = options.untilTimeMs !== undefined; + if (hasSinceTime !== hasUntilTime) { + throw new Error("Exact usage aggregation requires both time bounds"); + } + if (options.resolution === "hour" && !hasSinceTime) { + throw new Error("Hourly usage aggregation requires exact time bounds"); + } + if (options.resolution === "exactDay" && !hasSinceTime) { + throw new Error("Exact-day usage aggregation requires exact time bounds"); } + this.#exactWindow = + hasSinceTime && hasUntilTime + ? { + sinceTimeMs: options.sinceTimeMs, + untilTimeMs: options.untilTimeMs, + } + : null; + this.#hourlyWindow = options.resolution === "hour" ? this.#exactWindow : null; } /** @@ -182,9 +191,9 @@ export class UsageAggregator { } if ( - this.#hourlyWindow !== null && - (record.timestampMs < this.#hourlyWindow.sinceTimeMs || - record.timestampMs >= this.#hourlyWindow.untilTimeMs) + this.#exactWindow !== null && + (record.timestampMs < this.#exactWindow.sinceTimeMs || + record.timestampMs >= this.#exactWindow.untilTimeMs) ) { this.#outOfWindow += 1; return false; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7800603f2..26b8c58fb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -218,6 +218,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as TokenAccountingService from "./tokenAccounting/TokenAccountingService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; @@ -1237,6 +1238,7 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => null), ); const usage = yield* UsageService.UsageService; + const tokenAccounting = yield* TokenAccountingService.TokenAccountingService; const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; @@ -1668,6 +1670,9 @@ const makeWsRpcLayer = ( yield* serverSettings.getSettings, ); const environment = yield* serverEnvironment.getDescriptor; + const capabilities = { ...environment.capabilities }; + delete capabilities.savedTokenAccounting; + if (yield* tokenAccounting.isAvailable) capabilities.savedTokenAccounting = true; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; const editorConfig = yield* resolveEditorConfig( @@ -1676,7 +1681,7 @@ const makeWsRpcLayer = ( ); return { - environment, + environment: { ...environment, capabilities }, auth, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, @@ -2625,6 +2630,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverReadTokenAccounting]: (_input) => + observeRpcEffect(WS_METHODS.serverReadTokenAccounting, tokenAccounting.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRefreshUsageRates]: (_input) => observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/usage/SavedTokenAccounting.test.tsx b/apps/web/src/components/usage/SavedTokenAccounting.test.tsx new file mode 100644 index 000000000..f9480c541 --- /dev/null +++ b/apps/web/src/components/usage/SavedTokenAccounting.test.tsx @@ -0,0 +1,607 @@ +// @vitest-environment jsdom +import { + EnvironmentId, + TOKEN_ACCOUNTING_CAVEATS, + TOKEN_ACCOUNTING_IDENTITY_ALGORITHM, + TOKEN_ACCOUNTING_PROJECTION_SCHEMA, + TOKEN_ACCOUNTING_REPORT_SCHEMA, + TokenAccountingReadResult, + type TokenAccountingMetric, + type TokenAccountingReport, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + AVAILABLE_CONNECTION_STATE, + type SupervisorConnectionState, +} from "@t3tools/client-runtime/connection"; +import * as Schema from "effect/Schema"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { StrictMode, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + presentations: new Map(), + connections: new Map(), + read: vi.fn(), +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => + atom === "presentations" ? testState.presentations : testState.connections.get(atom), +})); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: "presentations" }, +})); +vi.mock("../../connection/catalog", () => ({ + environmentCatalog: { stateAtom: (environmentId: EnvironmentId) => environmentId }, +})); +vi.mock("../../state/server", () => ({ + serverEnvironment: { readTokenAccounting: "read-accounting" }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => testState.read })); + +import { SavedTokenAccounting } from "./SavedTokenAccounting"; + +const REPORT_ID = "a".repeat(64); +const READ_AT = "2026-10-02T15:00:00Z"; +const primaryId = EnvironmentId.make("primary"); + +function metric(value: number): TokenAccountingMetric { + return { total: value, known_sum: value, known_requests: 2, missing_requests: 0 }; +} + +// This synthetic projection proves client behavior, not original source identity. +function report(): TokenAccountingReport { + const status = { primary: 2, legacy_unresolved: 1, conflict: 1, aggregate_delta: 1 }; + const metrics = { + input_tokens: metric(100), + cached_input_tokens: metric(20), + cache_write_input_tokens: metric(10), + cache_write_5m_tokens: metric(10), + cache_write_1h_tokens: metric(0), + output_tokens: metric(10), + reasoning_output_tokens: metric(3), + }; + const partition = { requests: 2, metrics: { ...metrics, ordinary_input: metric(70) } }; + const absent: TokenAccountingMetric = { + total: null, + known_sum: null, + known_requests: 0, + missing_requests: 0, + }; + const emptyPartition = { + requests: 0, + metrics: { + input_tokens: absent, + cached_input_tokens: absent, + cache_write_input_tokens: absent, + cache_write_5m_tokens: absent, + cache_write_1h_tokens: absent, + output_tokens: absent, + reasoning_output_tokens: absent, + ordinary_input: absent, + }, + }; + return { + schema: TOKEN_ACCOUNTING_PROJECTION_SCHEMA, + source_schema: TOKEN_ACCOUNTING_REPORT_SCHEMA, + source_identity_algorithm: TOKEN_ACCOUNTING_IDENTITY_ALGORITHM, + source_identity_verified: true, + report_id: REPORT_ID, + selection_id: "b".repeat(64), + snapshot: { + index_snapshot_id: "c".repeat(64), + captured_at: "2026-10-01T11:00:00Z", + source_validation: "archive_snapshot_only", + }, + window: { start: "2026-09-01T00:00:00Z", end: "2026-10-01T00:00:00Z", end_exclusive: true }, + provider_coverage: { + selected_requests: 5, + accounting_status_counts: status, + token_missingness: { + input_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + cache_write_5m_tokens: 0, + cache_write_1h_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + }, + latest_scan: { + captured_at: null, + selected_files: null, + refreshed_selected_files: null, + partial_selected_files: null, + root_scope_ids: [], + mtime_cutoff: null, + receipts_status_counts: null, + validated_files: null, + }, + cumulative_index: { + tracked_files: null, + retained_not_refreshed_files: null, + missing_tracked_files: null, + }, + freshness: { + status: "archive_snapshot_only", + requested_end: "2026-10-01T00:00:00Z", + last_scan_captured_at: null, + selected_validation_min: null, + selected_validation_max: null, + unrefreshed_files: null, + }, + historical_window_completeness: "not_proven", + }, + provider_ledger: { + requests: 5, + primary_requests: 2, + nonadditive_requests: 3, + accounting_status_counts: status, + metrics: { ...metrics, ordinary_input_tokens: metric(70), non_read_input_tokens: metric(80) }, + }, + input: { + ordinary_input: metric(70), + allocated: metric(60), + unknown: metric(10), + unknown_by_reason: { framing_or_hidden_unknown: metric(10) }, + estimated_coverage: 60 / 70, + groups: [ + { + group: "guidance", + subtype: "developer", + basis: "estimated_calibrated", + central: 60, + low: 50, + high: 70, + }, + ], + }, + output: { + output: metric(10), + measured_reasoning: metric(3), + allocated: metric(7), + unknown: metric(0), + unknown_by_reason: {}, + estimated_coverage: 1, + groups: [ + { + group: "assistant_text", + subtype: "final", + basis: "measured_component", + central: 7, + low: 7, + high: 7, + }, + ], + }, + partitions: { + provider: { codex: partition, claude: emptyPartition }, + role: { root: partition, child: emptyPartition, unknown: emptyPartition }, + }, + mechanism_flags: { nonadditive: true, counts: { new_content: 2, repeated_history: 2 } }, + estimator_status_counts: { qualified: 1, unavailable: 1, failed: 1 }, + coverage: { + primary_requests: 2, + captured_requests: 2, + unavailable_requests: 0, + complete_response_requests: 2, + input_allocated_requests: 2, + output_allocated_requests: 2, + reasoning_measured_requests: 2, + reasoning_missing_requests: 0, + estimator_qualified_models: 1, + estimator_unavailable_models: 2, + diagnostics: {}, + }, + caveats: TOKEN_ACCOUNTING_CAVEATS, + authority_effect: "none", + }; +} + +function ready(saved = report()): TokenAccountingReadResult { + return Schema.decodeUnknownSync(TokenAccountingReadResult)({ + state: "ready", + report: saved, + readAt: READ_AT, + }); +} + +function environment( + id: EnvironmentId, + { + primary = true, + capability = true, + phase = "connected", + }: { + readonly primary?: boolean; + readonly capability?: boolean | "omitted"; + readonly phase?: "connected" | "offline"; + } = {}, +) { + return { + entry: { + target: { + environmentId: id, + label: id, + _tag: primary ? "PrimaryConnectionTarget" : "BearerConnectionTarget", + }, + enabled: true, + }, + connection: { phase }, + serverConfig: { + environment: { + capabilities: capability === "omitted" ? {} : { savedTokenAccounting: capability }, + }, + }, + }; +} + +function connect( + id = primaryId, + generation = 1, + phase: SupervisorConnectionState["phase"] = "connected", +) { + testState.connections = new Map(testState.connections).set( + id, + AsyncResult.success({ ...AVAILABLE_CONNECTION_STATE, phase, generation }), + ); + const presentation = testState.presentations.get(id); + if (presentation !== undefined) { + // State updates rebuild the real presentation and map. Preserve the supplied + // projected phase so the lag test can still exercise the connection guard. + testState.presentations = new Map(testState.presentations).set(id, { ...presentation }); + } +} + +function deferredRead() { + let resolve!: (value: AtomCommandResult) => void; + const promise = new Promise>((done) => { + resolve = done; + }); + testState.read.mockReturnValueOnce(promise); + return (value = ready()) => resolve(AsyncResult.success(value)); +} + +describe("saved token accounting panel", () => { + let renderer: Root; + let container: HTMLDivElement; + let mounted: boolean; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + renderer = createRoot(container); + mounted = true; + testState.read.mockReset().mockResolvedValue(AsyncResult.success(ready())); + testState.presentations = new Map([[primaryId, environment(primaryId)]]); + testState.connections = new Map(); + connect(); + }); + + afterEach(async () => { + if (mounted) await act(() => renderer.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + async function render() { + await act(() => + renderer.render( + + + , + ), + ); + } + + async function openPanel() { + const panel = container.querySelector("details"); + if (panel && !panel.open) await act(() => panel.querySelector("summary")!.click()); + } + + async function click(label: string) { + await openPanel(); + const button = [...container.querySelectorAll("button")].find( + (item) => item.textContent?.trim() === label, + ); + expect(button).toBeDefined(); + await act(() => button!.click()); + } + + function metricText(region: Element, label: string) { + return [...region.querySelectorAll("dt")].find((term) => term.textContent === label) + ?.nextElementSibling?.textContent; + } + + it("reads only on an explicit click, including after disclosure, rerender and reconnect", async () => { + await render(); + const details = container.querySelector("details")!; + expect(details.open).toBe(false); + await act(() => container.querySelector("summary")!.click()); + await render(); + expect(testState.read).not.toHaveBeenCalled(); + await click("Load saved report"); + expect(testState.read).toHaveBeenCalledExactlyOnceWith({ environmentId: primaryId, input: {} }); + expect(container.textContent).toContain(REPORT_ID); + const observation = [...container.querySelectorAll("dt")].find( + (term) => term.textContent === "Read observation", + )?.nextElementSibling; + expect(observation?.querySelector("time")?.dateTime).toBe(READ_AT); + expect(observation?.textContent).toContain("EDT"); + expect(container.textContent).toContain("end exclusive"); + expect(container.textContent).toContain("independent of Usage date, provider"); + await render(); + expect(testState.read).toHaveBeenCalledTimes(1); + connect(primaryId, 1, "offline"); + await render(); + expect(container.textContent).not.toContain(REPORT_ID); + connect(primaryId, 2); + await render(); + expect(container.textContent).not.toContain(REPORT_ID); + expect(testState.read).toHaveBeenCalledTimes(1); + await click("Read again"); + expect(testState.read).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain(REPORT_ID); + }); + + it.each(["omitted", false] as const)( + "does not expose or dispatch a reader when capability is %s", + async (capability) => { + testState.presentations = new Map(testState.presentations).set( + primaryId, + environment(primaryId, { capability }), + ); + await render(); + expect(container.textContent).toBe(""); + expect(testState.read).not.toHaveBeenCalled(); + }, + ); + + it("never dispatches while disconnected, even when presentation is one update behind", async () => { + connect(primaryId, 1, "offline"); + await render(); + await openPanel(); + const button = container.querySelector("button")!; + expect(button.disabled).toBe(true); + await act(() => button.click()); + expect(testState.read).not.toHaveBeenCalled(); + expect(container.textContent).toContain("disconnected"); + testState.presentations = new Map(testState.presentations).set( + primaryId, + environment(primaryId, { phase: "offline" }), + ); + await render(); + expect(container.textContent).toContain("Connect an environment"); + expect(testState.read).not.toHaveBeenCalled(); + }); + + it("discards an in-flight result after a connection generation changes", async () => { + const finish = deferredRead(); + await render(); + await click("Load saved report"); + expect(container.querySelector("button")?.disabled).toBe(true); + await act(() => container.querySelector("button")!.click()); + expect(testState.read).toHaveBeenCalledTimes(1); + connect(primaryId, 2); + await render(); + await act(() => finish()); + expect(container.textContent).not.toContain(REPORT_ID); + expect(testState.read).toHaveBeenCalledTimes(1); + await click("Read again"); + expect(container.textContent).toContain(REPORT_ID); + }); + + it("clears results on a target change and drops a late result from the old environment", async () => { + await render(); + await click("Load saved report"); + const finish = deferredRead(); + await click("Read again"); + const remote = EnvironmentId.make("remote"); + testState.presentations = new Map([[remote, environment(remote, { primary: false })]]); + connect(remote); + await render(); + await act(() => finish()); + expect(container.textContent).not.toContain(REPORT_ID); + expect(testState.read).toHaveBeenCalledTimes(2); + await click("Load saved report"); + expect(testState.read).toHaveBeenLastCalledWith({ environmentId: remote, input: {} }); + }); + + it("drops a late result after unmount and remount starts without a report", async () => { + const finish = deferredRead(); + await render(); + await click("Load saved report"); + await act(() => renderer.unmount()); + mounted = false; + await act(() => finish()); + renderer = createRoot(container); + mounted = true; + await render(); + expect(container.textContent).not.toContain(REPORT_ID); + expect(testState.read).toHaveBeenCalledTimes(1); + }); + + it("clears an in-flight read when the environment stops advertising the capability", async () => { + const finish = deferredRead(); + await render(); + await click("Load saved report"); + testState.presentations = new Map(testState.presentations).set( + primaryId, + environment(primaryId, { capability: "omitted" }), + ); + await render(); + await act(() => finish()); + expect(container.textContent).toBe(""); + testState.presentations = new Map(testState.presentations).set( + primaryId, + environment(primaryId), + ); + await render(); + expect(container.textContent).not.toContain(REPORT_ID); + expect(testState.read).toHaveBeenCalledTimes(1); + }); + + it("requires an explicit target with several capable non-primary environments", async () => { + const first = EnvironmentId.make("first"); + const second = EnvironmentId.make("second"); + testState.presentations = new Map([ + [first, environment(first, { primary: false })], + [second, environment(second, { primary: false })], + ]); + connect(first); + connect(second); + await render(); + await openPanel(); + expect(container.textContent).toContain("Choose an environment to load"); + expect(testState.read).not.toHaveBeenCalled(); + const trigger = container.querySelector( + '[aria-label="Saved report environment"]', + )!; + await act(() => trigger.click()); + const option = [...document.querySelectorAll('[role="option"]')].find( + (item) => item.textContent?.trim() === "second", + ); + expect(option).toBeDefined(); + await act(() => option!.click()); + expect(testState.read).not.toHaveBeenCalled(); + await click("Load saved report"); + expect(testState.read).toHaveBeenCalledExactlyOnceWith({ environmentId: second, input: {} }); + }); + + it("renders partial sums, wholly unknown metrics and coverage without zero-filling", async () => { + const saved = report(); + testState.read.mockResolvedValue( + AsyncResult.success( + ready({ + ...saved, + provider_ledger: { + ...saved.provider_ledger, + metrics: { + ...saved.provider_ledger.metrics, + input_tokens: { total: null, known_sum: 42, known_requests: 1, missing_requests: 1 }, + reasoning_output_tokens: { + total: null, + known_sum: null, + known_requests: 0, + missing_requests: 2, + }, + }, + }, + }), + ), + ); + await render(); + await click("Load saved report"); + const ledger = container.querySelector('[aria-label="Saved provider ledger"]')!; + expect(metricText(ledger, "Input")).toBe( + "Known: 42 · total unknown1 known · 1 missing requests", + ); + expect(metricText(ledger, "Measured reasoning · included in output")).toBe( + "Unknown0 known · 2 missing requests", + ); + expect(container.textContent).toContain("Unknown residuals are not zero"); + expect(metricText(container, "Tracked files")).toBe("Unknown"); + }); + + it("keeps excluded statuses, estimator failures and overlapping mechanism counts visible", async () => { + await render(); + await click("Load saved report"); + expect(metricText(container, "Primary · additive")).toBe("2"); + expect(metricText(container, "Excluded · nonadditive")).toBe("3"); + expect(metricText(container, "Legacy unresolved · excluded")).toBe("1"); + expect(metricText(container, "Conflict · excluded")).toBe("1"); + expect(metricText(container, "Aggregate delta · excluded")).toBe("1"); + expect(metricText(container, "Qualified estimators")).toBe("1"); + expect(metricText(container, "Unavailable estimators")).toBe("1"); + expect(metricText(container, "Failed estimators")).toBe("1"); + expect(metricText(container, "New content")).toBe("2"); + expect(metricText(container, "Repeated history")).toBe("2"); + expect(metricText(container, "Compaction or restart")).toBe("Unknown"); + expect(container.textContent).toContain("counts overlap and must not be summed"); + expect(container.textContent).toContain("Provider and role are separate views"); + expect(container.querySelector('[aria-label="Input allocation"]')?.textContent).toContain( + "Estimated · calibrated", + ); + expect(container.querySelector('[aria-label="Output allocation"]')?.textContent).toContain( + "Measured component", + ); + expect(container.querySelectorAll("li")).toHaveLength(TOKEN_ACCOUNTING_CAVEATS.length); + }); + + it("preserves an all-indexed-history window and partial indexed selection", async () => { + const saved = report(); + testState.read.mockResolvedValue( + AsyncResult.success( + ready({ + ...saved, + window: { scope: "all_indexed_history", start: null, end: null, end_exclusive: false }, + provider_coverage: { + ...saved.provider_coverage, + history_scope: "all_indexed_history", + indexed_history_selection: { + requested_thread_count: 2, + indexed_thread_count: 1, + status: "partially_indexed", + }, + }, + }), + ), + ); + await render(); + await click("Load saved report"); + expect(metricText(container, "Saved report window")).toBe("All indexed history"); + expect(metricText(container, "Requested threads")).toBe("2"); + expect(metricText(container, "Indexed threads")).toBe("1"); + expect(metricText(container, "Indexed history selection")).toBe("partially indexed"); + expect(container.textContent).toContain("Historical window completeness is not proven"); + }); + + it.each([ + { status: "unconfigured", reason: "reader_unconfigured", text: "has not been configured" }, + { status: "missing", reason: "configured_report_missing", text: "is missing" }, + { status: "invalid", reason: "report_identity_mismatch", text: "identity check" }, + { status: "unsupported", reason: "report_schema_unsupported", text: "format is not supported" }, + { status: "oversized", reason: "projection_too_large", text: "response size limit" }, + { status: "reader_failed", reason: "reader_timeout", text: "did not finish in time" }, + ])( + "renders the fixed $status unavailable state with read observation", + async ({ status, reason, text }) => { + const unavailable = Schema.decodeUnknownSync(TokenAccountingReadResult)({ + state: "unavailable", + status, + reason, + readAt: READ_AT, + configuredReportId: REPORT_ID, + }); + testState.read.mockResolvedValue(AsyncResult.success(unavailable)); + await render(); + await click("Load saved report"); + expect(container.querySelector('[role="status"]')?.textContent).toContain(text); + expect(metricText(container, "Configured report ID")).toBe(REPORT_ID); + expect(container.querySelector("time")?.dateTime).toBe(READ_AT); + }, + ); + + it("shows a generic transport error without displaying an arbitrary exception", async () => { + testState.read.mockRejectedValue(new Error("synthetic private exception text")); + await render(); + await click("Load saved report"); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "The saved report could not be read. Try again when connected.", + ); + expect(container.textContent).not.toContain("synthetic private exception text"); + }); + + it("also shows a generic message for a settled transport failure", async () => { + testState.read.mockResolvedValue(AsyncResult.fail(new Error("synthetic transport failure"))); + await render(); + await click("Load saved report"); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "The saved report could not be read. Try again when connected.", + ); + expect(container.textContent).not.toContain("synthetic transport failure"); + }); +}); diff --git a/apps/web/src/components/usage/SavedTokenAccounting.tsx b/apps/web/src/components/usage/SavedTokenAccounting.tsx new file mode 100644 index 000000000..777d47c46 --- /dev/null +++ b/apps/web/src/components/usage/SavedTokenAccounting.tsx @@ -0,0 +1,694 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentId, + TokenAccountingMetric, + TokenAccountingReadResult, + TokenAccountingReport, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useState, type ReactNode } from "react"; + +import { environmentCatalog } from "../../connection/catalog"; +import { environmentPresentations } from "../../state/presentation"; +import { serverEnvironment } from "../../state/server"; +import { + ACCOUNTING_TRANSPORT_ERROR, + accountingUnavailableMessage, + formatAccountingCount, + formatAccountingMetric, + selectAccountingEnvironment, + useSavedTokenAccounting, + type AccountingEnvironment, +} from "../../state/tokenAccounting"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +const TIMESTAMP = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", +}); + +const CAVEATS: Record = { + primary_requests_only_are_additive: + "Only primary requests are additive. Legacy unresolved, conflict and aggregate delta requests are excluded from additive totals.", + missing_counters_leave_totals_unknown: + "Missing counters leave complete totals unknown; a known sum covers only requests with a recorded counter.", + visible_inventory_is_not_submitted_payload: + "Visible inventory does not establish what was submitted to the provider.", + user_role_does_not_prove_owner_speech: + "A user-role message does not prove that the owner spoke it.", + candidate_envelope_is_unproved_submission: + "A reconstructed candidate envelope does not prove actual submission.", + cache_placement_unsupported: + "Allocation groups do not establish where cache reads or cache writes occurred.", + unknown_residuals_are_not_zero: "Unknown residuals are not zero.", + reasoning_is_an_output_subset: + "Measured reasoning is included in output, rather than added to it.", + mechanism_flags_are_nonadditive: "Mechanism flags overlap and must not be summed.", + counter_changes_are_diagnostics_only: + "Counter changes are diagnostics, not additional requests or token allocations.", + bands_use_held_out_group_bias: + "Allocation bands use held-out group bias; estimated allocations are not measured provider counters.", + historical_window_completeness_not_proven: "Historical window completeness is not proven.", + append_only_source_validation_limit: + "Append-only source validation does not establish complete historical coverage.", + partial_counter_masks_limit_aggregate_conservation: + "Partial counter coverage limits aggregate conservation claims.", +}; + +const FRESHNESS: Record = + { + scan_unavailable: "Scan information unavailable", + archive_snapshot_only: "Archive snapshot only", + validation_receipts_unavailable: "Source validation receipts unavailable", + no_selected_source_validated: "No selected source validated", + requested_end_unspecified: "Requested end unspecified", + requested_end_after_selected_validation: "Requested end is after selected source validation", + retained_sources_not_refreshed: "Retained sources were not refreshed", + selected_sources_validated_after_requested_end: + "Selected sources validated after requested end", + }; + +const MECHANISMS: Record = { + new_content: "New content", + repeated_history: "Repeated history", + changed_guidance_schema_prefix: "Changed guidance or schema prefix", + compaction_restart: "Compaction or restart", + first_observation_or_missing_predecessor: "First observation or missing predecessor", + cache_miss_unchanged_reconstruction: "Cache miss with unchanged reconstruction", + mixed_unknown: "Mixed or unknown", +}; + +export function SavedTokenAccounting() { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const [selected, setSelected] = useState(null); + const environments: AccountingEnvironment[] = [...presentations].map( + ([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + primary: presentation.entry.target._tag === "PrimaryConnectionTarget", + connected: presentation.connection.phase === "connected", + supported: + presentation.entry.enabled !== false && + presentation.serverConfig?.environment?.capabilities.savedTokenAccounting === true, + }), + ); + const supported = environments.filter((environment) => environment.supported); + if (supported.length === 0) return null; + const eligible = supported.filter((environment) => environment.connected); + const target = selectAccountingEnvironment(environments, selected); + + return ( +
+ + Saved token accounting + +
+

+ Read one saved report from one environment. Its window is independent of Usage date, + provider and environment filters and may differ from the activity above. It is not a + billing total. +

+ {eligible.length > 1 ? ( +
+ +
+ ) : null} + {target === null ? ( +

+ {eligible.length === 0 + ? "Connect an environment with a saved accounting reader to load its report." + : "Choose an environment to load its saved report."} +

+ ) : ( + + )} +
+
+ ); +} + +function AccountingReader({ environment }: { readonly environment: AccountingEnvironment }) { + const connection = useAtomValue(environmentCatalog.stateAtom(environment.environmentId)); + const read = useAtomCommand(serverEnvironment.readTokenAccounting, { reportFailure: false }); + const target = + environment.connected && + AsyncResult.isSuccess(connection) && + connection.value.phase === "connected" + ? { environmentId: environment.environmentId, generation: connection.value.generation } + : null; + const { state, load } = useSavedTokenAccounting(target, read); + const [attempted, setAttempted] = useState(false); + + return ( +
+
+ {environment.label} + +
+ {target === null ? ( +

+ The environment is disconnected. Connect again, then load its saved report. +

+ ) : state.phase === "error" ? ( +

+ {ACCOUNTING_TRANSPORT_ERROR} +

+ ) : state.phase === "observed" ? ( + + ) : null} +
+ ); +} + +function AccountingObservation({ result }: { readonly result: TokenAccountingReadResult }) { + if (result.state === "unavailable") + return ( +
+

+ {accountingUnavailableMessage(result)} +

+
+ } /> + {result.configuredReportId === null ? null : ( + + )} +
+
+ ); + return ; +} + +function AccountingReport({ + report, + readAt, +}: { + readonly report: TokenAccountingReport; + readonly readAt: string; +}) { + const ledger = report.provider_ledger; + const coverage = report.provider_coverage; + return ( +
+
+ + } /> + + – {" "} + (end exclusive) + + ) + } + /> + +
+

+ Only primary requests are additive. Unknown residuals are not zero. Historical window + completeness is not proven. +

+
+

Provider ledger

+
+ + + + + + +
+
+ + + + + + + + + +
+

+ Cache reads and cache writes remain separate recorded counters. Allocation groups do not + establish cache placement. Reasoning is a subset of output. +

+
+
+ + +
+
+

Capture and estimator coverage

+
+ + + + + + + + + + +
+
+
+ + +
+

+ Provider and role are separate views of the same primary requests. Do not add these + partitions together. +

+
+

Mechanisms · overlapping, nonadditive

+
+ {Object.entries(MECHANISMS).map(([key, label]) => ( + + ))} +
+

+ A request can have several mechanism flags. These counts overlap and must not be summed. +

+
+
+ + Source coverage, snapshot and caveats + +
+
+ + + + ) + } + /> + + + + + + ) + } + /> + + + + + + + + + ) + } + /> + + ) + } + /> + {coverage.indexed_history_selection === undefined ? null : ( + <> + + + + + )} +
+
    + {report.caveats.map((caveat) => ( +
  • {CAVEATS[caveat]}
  • + ))} +
+
+
+
+ ); +} + +type AllocationData = TokenAccountingReport["input"] | TokenAccountingReport["output"]; + +function Allocation({ + title, + totalLabel, + total, + allocation, + reasoning, +}: { + readonly title: string; + readonly totalLabel: string; + readonly total: TokenAccountingMetric; + readonly allocation: AllocationData; + readonly reasoning?: TokenAccountingMetric; +}) { + return ( +
+

{title}

+
+ + + + {reasoning === undefined ? null : ( + + )} + +
+
+ + + + + {["Group", "Basis", "Central", "Low", "High"].map((label) => ( + + ))} + + + + {allocation.groups.length === 0 ? ( + + + + ) : ( + allocation.groups.map((group) => ( + + + + {[group.central, group.low, group.high].map((value, index) => ( + + ))} + + )) + )} + +
+ {title} groups with basis and central, low and high bands +
+ {label} +
+ No allocation groups recorded. +
+ {readable(group.group)} · {readable(group.subtype)} + + {group.basis === "measured_component" + ? "Measured component" + : "Estimated · calibrated"} + + {formatAccountingCount(value)} +
+
+

+ Estimated bands are separate from measured provider counters. +

+ {Object.keys(allocation.unknown_by_reason).length === 0 ? null : ( +
+ {Object.entries(allocation.unknown_by_reason).map(([reason, metric]) => ( + + ))} +
+ )} +
+ ); +} + +function Partitions({ + title, + partitions, + labels, +}: { + readonly title: string; + readonly partitions: Record; + readonly labels: Record; +}) { + return ( +
+

{title}

+
+ + + + {[title, "Requests", "Input", "Output", "Reasoning · output subset"].map((label) => ( + + ))} + + + + {(Object.keys(labels) as Key[]).map((key) => ( + + + + {[ + partitions[key].metrics.input_tokens, + partitions[key].metrics.output_tokens, + partitions[key].metrics.reasoning_output_tokens, + ].map((metric, index) => ( + + ))} + + ))} + +
+ {label} +
+ {labels[key]} + + {formatAccountingCount(partitions[key].requests)} + + +
+
+
+ ); +} + +function MetricValue({ metric }: { readonly metric: TokenAccountingMetric }) { + return ( + <> + {formatAccountingMetric(metric)} + + {formatAccountingCount(metric.known_requests)} known ·{" "} + {formatAccountingCount(metric.missing_requests)} missing requests + + + ); +} + +function Metric({ + label, + metric, +}: { + readonly label: string; + readonly metric: TokenAccountingMetric; +}) { + return } />; +} + +function CountMetric({ label, value }: { readonly label: string; readonly value: number }) { + return ; +} + +function TextMetric({ label, value }: { readonly label: string; readonly value: ReactNode }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function Timestamp({ value }: { readonly value: string }) { + return ; +} + +function nullableCount(value: number | null | undefined): string { + return value === null || value === undefined ? "Unknown" : formatAccountingCount(value); +} + +function readable(value: string): string { + return value.replaceAll("_", " "); +} diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index 61ad487ad..083940c66 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -88,6 +88,7 @@ vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })) vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); +vi.mock("./SavedTokenAccounting", () => ({ SavedTokenAccounting: () => null })); vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) })); diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 6d8caac6a..ee2747edd 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,9 +1,18 @@ -import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { + EnvironmentId, + UsageDay, + USAGE_CONTRACT_VERSION, + type UsageBucket, + type UsageSummary, +} from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { act } from "react"; +import type { ReactElement, ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { saveUsagePagePreferences } from "./usagePagePreferences"; + const testState = vi.hoisted(() => ({ useUsage: vi.fn(), navigate: vi.fn(), @@ -25,7 +34,32 @@ vi.mock("../ui/select", () => ({ SelectValue: "div", })); vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); -vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../ui/toggle-group", async () => { + const React = await import("react"); + return { + Toggle: "button", + ToggleGroup: ({ + children, + onValueChange, + ...props + }: { + readonly children?: ReactNode; + readonly onValueChange?: (value: readonly string[]) => void; + readonly [key: string]: unknown; + }) => + React.createElement( + "div", + props, + React.Children.map(children, (child) => { + if (!React.isValidElement<{ value: string }>(child)) return child; + const toggle = child as ReactElement<{ value: string; onClick?: () => void }>; + return React.cloneElement(toggle, { + onClick: () => onValueChange?.([toggle.props.value]), + }); + }), + ), + }; +}); vi.mock("../WorkspaceBreadcrumb", () => ({ WorkspaceBreadcrumb: "div", WorkspaceBreadcrumbItem: "div", @@ -33,8 +67,23 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ })); vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsageProviderChart", async () => { + const React = await import("react"); + return { + UsageProviderChart: ({ + daily, + }: { + readonly daily: readonly { costUsd: number; totalTokens: number }[]; + }) => + React.createElement("div", { + "data-testid": "usage-chart", + "data-cost": daily.reduce((sum, day) => sum + day.costUsd, 0), + "data-tokens": daily.reduce((sum, day) => sum + day.totalTokens, 0), + }), + }; +}); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); +vi.mock("./SavedTokenAccounting", () => ({ SavedTokenAccounting: () => null })); vi.mock("./usageProviders", async (importOriginal) => { const actual = await importOriginal(); return { @@ -63,7 +112,7 @@ const environments = [ sources: [], pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 }, scanDurationMs: 1, - }, + } satisfies UsageSummary, }, ]; @@ -84,6 +133,7 @@ describe("UsagePage Escape navigation", () => { let back: ReturnType; beforeEach(async () => { + saveUsagePagePreferences({ metric: "tokens", windowDays: 30 }); back = vi.spyOn(window.history, "back").mockImplementation(() => {}); testState.navigate.mockClear(); testState.canGoBack = true; @@ -101,6 +151,8 @@ describe("UsagePage Escape navigation", () => { container.remove(); back.mockRestore(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); }); function escape(properties: { repeat?: boolean; isComposing?: boolean } = {}) { @@ -147,6 +199,436 @@ describe("UsagePage Escape navigation", () => { expect(back).not.toHaveBeenCalled(); expect(testState.navigate).not.toHaveBeenCalled(); }); + + it("selects an exact three-hour range from the overflow panel", async () => { + const trigger = container.querySelector( + '[aria-label="Additional usage ranges"]', + ); + expect(trigger).not.toBeNull(); + + await act(() => trigger?.click()); + const threeHours = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "3h", + ); + expect(threeHours).toBeDefined(); + await act(() => threeHours?.click()); + + const input = testState.useUsage.mock.calls.at(-1)?.[0]; + expect(input).toMatchObject({ resolution: "hour", timeZone: expect.any(String) }); + expect(Date.parse(input.untilTime) - Date.parse(input.sinceTime)).toBe(3 * 60 * 60 * 1000); + }); + + it("applies and clears an exact multi-day custom range", async () => { + vi.stubEnv("TZ", "UTC"); + const trigger = container.querySelector( + '[aria-label="Additional usage ranges"]', + ); + expect(trigger).not.toBeNull(); + await act(() => trigger?.click()); + + const setInputValue = (label: string, value: string) => { + const input = document.querySelector(`[aria-label="${label}"]`); + expect(input).not.toBeNull(); + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + if (!input || !valueSetter) throw new Error("The custom range input is unavailable."); + valueSetter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + await act(() => setInputValue("Custom range start", "2026-09-15T10:30")); + await act(() => setInputValue("Custom range end", "2026-09-18T12:37")); + const apply = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Apply range", + ); + expect(apply?.disabled).toBe(false); + await act(() => apply?.click()); + + const customInput = testState.useUsage.mock.calls.at(-1)?.[0]; + expect(customInput).toMatchObject({ + sinceDay: "2026-09-15", + untilDay: "2026-09-18", + timeZone: "UTC", + resolution: "exactDay", + sinceTime: "2026-09-15T10:30:00.000Z", + untilTime: "2026-09-18T12:37:00.000Z", + }); + + await act(() => trigger?.click()); + const clear = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Clear custom selection", + ); + expect(clear?.disabled).toBe(false); + await act(() => clear?.click()); + expect(testState.useUsage.mock.calls.at(-1)?.[0]).not.toHaveProperty("sinceTime"); + }); + + it("preserves repeated-hour bounds when applying an unchanged rolling prefill", async () => { + vi.stubEnv("TZ", "America/New_York"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-01T06:30:00.000Z")); + const trigger = container.querySelector( + '[aria-label="Additional usage ranges"]', + )!; + await act(() => trigger.click()); + const oneHour = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "1h", + )!; + await act(() => oneHour.click()); + const rollingInput = testState.useUsage.mock.calls.at(-1)?.[0]; + expect(rollingInput).toMatchObject({ + sinceTime: "2026-11-01T05:30:00.000Z", + untilTime: "2026-11-01T06:30:00.000Z", + }); + + await act(() => trigger.click()); + for (const label of ["Custom range start", "Custom range end"]) { + expect(document.querySelector(`[aria-label="${label}"]`)?.value).toBe( + "2026-11-01T01:30", + ); + } + const apply = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Apply range", + )!; + expect(apply.disabled).toBe(false); + await act(() => apply.click()); + expect(testState.useUsage.mock.calls.at(-1)?.[0]).toEqual(rollingInput); + }); + + it("explains an edited repeated local time instead of applying an arbitrary offset", async () => { + vi.stubEnv("TZ", "America/New_York"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-01T08:00:00.000Z")); + const trigger = container.querySelector( + '[aria-label="Additional usage ranges"]', + )!; + await act(() => trigger.click()); + const inputs = [ + ["Custom range start", "2026-11-01T01:30"], + ["Custom range end", "2026-11-01T02:30"], + ]; + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + for (const [label, value] of inputs) { + const input = document.querySelector(`[aria-label="${label}"]`)!; + await act(() => { + valueSetter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + const apply = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Apply range", + )!; + expect(apply.disabled).toBe(true); + expect(document.querySelector('[role="alert"]')?.textContent).toBe( + "This local time occurs twice when clocks move back. Choose a time outside the repeated hour or keep the original range time unchanged.", + ); + expect(testState.useUsage.mock.calls.at(-1)?.[0]).not.toHaveProperty("sinceTime"); + }); +}); + +function usageFixture(includeCodex = true, environmentId = "test-environment") { + const buckets: UsageBucket[] = [ + ...(includeCodex + ? [ + { + provider: "codex" as const, + model: "codex-known", + day: UsageDay.make("2026-08-11"), + hourStart: "2026-08-11T10:00:00.000Z", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 200, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 10, + }, + records: 2, + sessions: 1, + costUsd: 5, + cacheSavingsUsd: 1, + costSource: "modelPriced" as const, + unpricedRecords: 0, + }, + { + provider: "codex" as const, + model: "codex-unpriced", + day: UsageDay.make("2026-08-11"), + hourStart: "2026-08-11T10:00:00.000Z", + totals: { + uncachedInputTokens: 10, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 20, + reasoningTokens: 5, + }, + records: 1, + sessions: 1, + costUsd: 0, + cacheSavingsUsd: 0, + costSource: "unpriced" as const, + unpricedRecords: 1, + }, + ] + : []), + { + provider: "claude", + model: "claude-known", + day: UsageDay.make("2026-08-10"), + hourStart: "2026-08-10T09:00:00.000Z", + totals: { + uncachedInputTokens: 300, + cachedInputTokens: 400, + cacheCreationTokens: 50, + outputTokens: 60, + reasoningTokens: 0, + }, + records: 3, + sessions: 2, + costUsd: 10, + cacheSavingsUsd: 2, + costSource: "providerReported", + unpricedRecords: 0, + }, + ]; + const summary: UsageSummary = { + ...environments[0]!.summary, + buckets, + sources: [...new Set(buckets.map((bucket) => bucket.provider))].map((provider) => ({ + fingerprint: { + provider, + hostId: environmentId, + resolvedHomePath: `/${provider}`, + volumeId: environmentId, + }, + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: provider === "codex" ? 1 : 2, + message: null, + })), + }; + const environment = { + ...environments[0]!, + environmentId: EnvironmentId.make(environmentId), + summary, + }; + return { + merged: mergeUsage([environment], USAGE_CONTRACT_VERSION), + environments: [environment], + selectedEnvironments: [environment], + isPending: false, + isPartial: false, + refresh: vi.fn(async () => undefined), + }; +} + +describe("UsagePage provider and model details", () => { + let renderer: Root; + let container: HTMLDivElement; + + beforeEach(async () => { + saveUsagePagePreferences({ metric: "tokens", windowDays: 30 }); + testState.useUsage.mockReturnValue(usageFixture()); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + renderer = createRoot(container); + await act(() => renderer.render()); + }); + + afterEach(async () => { + await act(() => renderer.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function clickLabel(label: string) { + const button = container.querySelector(`button[aria-label="${label}"]`); + expect(button).not.toBeNull(); + await act(() => button!.click()); + } + + async function clickText(text: string) { + const button = [...container.querySelectorAll("button")].find( + (item) => item.textContent?.trim() === text, + ); + expect(button).toBeDefined(); + await act(() => button!.click()); + } + + function details() { + const section = container.querySelector("#usage-provider-details"); + expect(section).not.toBeNull(); + return section!; + } + + function metric(section: HTMLElement, label: string) { + return [...section.querySelectorAll("dt")].find((element) => element.textContent === label) + ?.nextElementSibling?.textContent; + } + + function modelNames() { + return [...container.querySelectorAll('tbody button[aria-label$="token details"]')].map( + (button) => button.textContent?.trim(), + ); + } + + it("opens one provider, scopes its details and shares, and keeps summary/chart aggregate", async () => { + const summary = container.querySelector(".text-4xl")?.textContent; + const chart = container.querySelector('[data-testid="usage-chart"]')!.outerHTML; + expect(modelNames()).toEqual(["claude-known", "codex-known", "codex-unpriced"]); + await clickLabel("Codex usage details"); + expect( + container.querySelector('[aria-label="Codex usage details"]')?.getAttribute("aria-expanded"), + ).toBe("true"); + expect(modelNames()).toEqual(["codex-known", "codex-unpriced"]); + expect(metric(details(), "Uncached input")).toBe("110"); + expect(metric(details(), "Cached input")).toBe("200"); + expect(metric(details(), "Cache creation")).toBe("30"); + expect(metric(details(), "Output")).toBe("60"); + expect(metric(details(), "Reasoning · included in output")).toBe("15"); + expect(metric(details(), "Processed tokens")).toBe("400"); + expect(metric(details(), "Recorded responses")).toBe("3"); + expect(metric(details(), "Model priced")).toBe("2"); + expect(metric(details(), "Unpriced")).toBe("1"); + expect(container.querySelector("tbody tr")?.textContent).toContain("100.0%"); + expect(container.textContent).toContain("Shares are within Codex's API estimate"); + expect(container.querySelector(".text-4xl")?.textContent).toBe(summary); + expect(container.querySelector('[data-testid="usage-chart"]')!.outerHTML).toBe(chart); + + await clickLabel("Claude Code usage details"); + expect( + container.querySelector('[aria-label="Codex usage details"]')?.getAttribute("aria-expanded"), + ).toBe("false"); + expect(details().textContent).toContain("Claude Code details"); + expect(metric(details(), "Provider reported")).toBe("3"); + expect(modelNames()).toEqual(["claude-known"]); + expect(container.querySelectorAll("#usage-provider-details")).toHaveLength(1); + + await clickText("All providers"); + expect(container.querySelector("#usage-provider-details")).toBeNull(); + expect(modelNames()).toHaveLength(3); + }); + + it("closes from the disclosure and close button, restoring aggregate rows and focus", async () => { + await clickLabel("Codex usage details"); + await clickLabel("Codex usage details"); + expect(container.querySelector("#usage-provider-details")).toBeNull(); + await clickLabel("Codex usage details"); + await clickLabel("Close Codex details"); + expect(container.querySelector("#usage-provider-details")).toBeNull(); + expect(modelNames()).toHaveLength(3); + expect(document.activeElement).toBe( + container.querySelector('[aria-label="Codex usage details"]'), + ); + }); + + it("filters daily and hourly totals and restores the complete time breakdown", async () => { + await clickLabel("Codex usage details"); + await clickText("Day"); + expect(container.querySelectorAll("tbody tr")).toHaveLength(1); + expect(container.querySelector("tbody")?.textContent).toContain("400"); + expect([...container.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual([ + "Day", + "Codex", + "Total", + "Tokens", + ]); + await clickText("All providers"); + expect(container.querySelectorAll("tbody tr")).toHaveLength(2); + + const range = container.querySelector( + '[aria-label="Additional usage ranges"]', + )!; + await act(() => range.click()); + const oneHour = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "1h", + )!; + await act(() => oneHour.click()); + await clickLabel("Codex usage details"); + expect([...container.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual([ + "Hour", + "Codex", + "Total", + "Tokens", + ]); + expect(container.querySelectorAll("tbody tr")).toHaveLength(1); + expect(container.querySelector("tbody")?.textContent).toContain("400"); + }); + + it("expands model counters and retains unpriced versus known zero-dollar costs", async () => { + await clickLabel("Codex usage details"); + await clickLabel("codex-unpriced token details"); + const modelDetails = container.querySelector( + '[role="region"][aria-label="codex-unpriced token details"]', + )!; + expect(metric(modelDetails, "Recorded responses")).toBe("1"); + expect(metric(modelDetails, "API estimate")).toBe("Unpriced"); + expect(metric(modelDetails, "Output")).toBe("20"); + expect(metric(modelDetails, "Reasoning · included in output")).toBe("5"); + await clickLabel("codex-known token details"); + expect( + container.querySelector('[role="region"][aria-label="codex-unpriced token details"]'), + ).toBeNull(); + + const fixture = usageFixture(); + testState.useUsage.mockReturnValue({ + ...fixture, + merged: { + ...fixture.merged, + models: fixture.merged.models.map((model) => + model.model === "codex-known" ? { ...model, costUsd: 0 } : model, + ), + }, + }); + await act(() => renderer.render()); + const known = container.querySelector( + '[role="region"][aria-label="codex-known token details"]', + )!; + expect(metric(known, "API estimate")).toBe("$0.00"); + }); + + it("recomputes from new environment data and clears a provider that disappears", async () => { + await clickLabel("Codex usage details"); + const next = usageFixture(true, "other-environment"); + testState.useUsage.mockReturnValue({ + ...next, + merged: { + ...next.merged, + providers: next.merged.providers.map((provider) => + provider.provider === "codex" ? { ...provider, records: 7 } : provider, + ), + }, + }); + await act(() => renderer.render()); + expect(metric(details(), "Recorded responses")).toBe("7"); + + testState.useUsage.mockReturnValue(usageFixture(false, "claude-environment")); + await act(() => renderer.render()); + expect(container.querySelector("#usage-provider-details")).toBeNull(); + expect(modelNames()).toEqual(["claude-known"]); + testState.useUsage.mockReturnValue(usageFixture()); + await act(() => renderer.render()); + expect(container.querySelector("#usage-provider-details")).toBeNull(); + expect(modelNames()).toHaveLength(3); + }); + + it("resets provider and model focus when the environment selection changes", async () => { + await clickLabel("Codex usage details"); + await clickLabel("codex-known token details"); + const trigger = container.querySelector('[data-slot="menu-trigger"]')!; + await act(() => trigger.click()); + const allEnvironments = [ + ...document.querySelectorAll('[role="menuitemcheckbox"]'), + ].find((item) => item.textContent?.trim() === "All environments")!; + expect(allEnvironments).toBeDefined(); + await act(() => allEnvironments.click()); + expect(testState.useUsage.mock.calls.at(-1)?.[1]).toEqual(new Set()); + expect(container.querySelector("#usage-provider-details")).toBeNull(); + expect( + container.querySelector('[role="region"][aria-label="codex-known token details"]'), + ).toBeNull(); + expect(modelNames()).toHaveLength(3); + }); }); // @vitest-environment jsdom diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 969ca5f4e..e443e35d3 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,4 +1,5 @@ import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; +import { SavedTokenAccounting } from "./SavedTokenAccounting"; import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; @@ -6,16 +7,18 @@ import { ProviderDriverKind, USAGE_CONTRACT_VERSION, type EnvironmentId, + type UsageSummaryInput, type UsageProviderKind, } from "@t3tools/contracts"; import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, + EllipsisIcon, InfoIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { Fragment, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { cursorKeychainAccessEnvironments, refreshUsageLimits, @@ -52,6 +55,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { Button, InlineButton } from "../ui/button"; +import { Input } from "../ui/input"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Menu, @@ -80,10 +84,16 @@ import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart } from "./UsageProviderChart"; import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; import { UsageShareBar } from "./UsageShareBar"; +import { + UsageProviderDetails, + UsageTokenDetails, + USAGE_PROVIDER_DETAILS_ID, +} from "./UsageProviderDetails"; import { costTypeSegments, modelShare, sortModelsByTokens, + selectUsageBreakdown, speedCostSegments, tokenTypeSegments, } from "./usageBreakdown"; @@ -100,6 +110,23 @@ import { saveUsagePagePreferences, type UsagePagePreferences, } from "./usagePagePreferences"; +import { + makeRollingUsageWindow, + toLocalDateTimeValue, + validateCustomUsageWindow, + type CustomUsageWindowValidation, +} from "./usageDateRange"; + +type UsageWindowSelection = + | { + readonly kind: "day"; + readonly days: UsagePagePreferences["windowDays"]; + readonly window: UsageSummaryInput; + } + | { readonly kind: "hours"; readonly hours: number; readonly window: UsageSummaryInput } + | { readonly kind: "custom"; readonly window: UsageSummaryInput }; + +const QUICK_USAGE_HOUR_OPTIONS = [1, 3, 6, 12] as const; function isUsageMetric(value: string | null | undefined): value is UsageMetric { return METRIC_OPTIONS.some((option) => option.value === value); @@ -121,7 +148,8 @@ export function UsagePage() { }); return shortcut ? `${option.label} (${shortcut})` : option.label; }; - const [windowSelection, setWindowSelection] = useState(() => ({ + const [windowSelection, setWindowSelection] = useState(() => ({ + kind: "day", days: preferences.windowDays, window: makeWindow( preferences.windowDays, @@ -131,16 +159,29 @@ export function UsagePage() { })); const metric = preferences.metric; const showingLimits = metric === "limits"; + const windowDays = windowSelection.kind === "day" ? windowSelection.days : preferences.windowDays; + const { window } = windowSelection; + const isHourly = window.resolution === "hour"; const [isRefreshing, setIsRefreshing] = useState(false); const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [selectedProvider, setSelectedProvider] = useState(null); + const [expandedModelKey, setExpandedModelKey] = useState(null); + const providerTriggers = useRef(new Map()); + const [customSinceValue, setCustomSinceValue] = useState(""); + const [customUntilValue, setCustomUntilValue] = useState(""); + const [customOriginalWindow, setCustomOriginalWindow] = useState(); + const customWindowValidation = validateCustomUsageWindow( + customSinceValue, + customUntilValue, + undefined, + customOriginalWindow, + ); const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null); const [selectedModelKey, setSelectedModelKey] = useState(null); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( window, selectedEnvironmentIds, @@ -173,25 +214,41 @@ export function UsagePage() { ); const hours = useMemo( () => - window.sinceTime === undefined || window.untilTime === undefined + !isHourly || window.sinceTime === undefined || window.untilTime === undefined ? [] : enumerateHourStarts(window.sinceTime, window.untilTime), - [window.sinceTime, window.untilTime], + [isHourly, window.sinceTime, window.untilTime], ); - // Newest first: the window can run 90 periods, so the interesting end + const detailBreakdown = useMemo( + () => selectUsageBreakdown(merged, selectedProvider), + [merged, selectedProvider], + ); + const focusedProvider = detailBreakdown.providerTotals?.provider ?? null; + // Newest first: the window can run 90 days, so the interesting end // belongs at the top of the table. const breakdownPeriods = useMemo( - () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), - [isPast24Hours, merged.daily, merged.hourly], + () => (isHourly ? detailBreakdown.hourly : detailBreakdown.daily).toReversed(), + [isHourly, detailBreakdown.daily, detailBreakdown.hourly], ); const breakdownModels = useMemo( () => breakdown === "model" && metric === "tokens" - ? sortModelsByTokens(merged.models) - : merged.models, - [breakdown, merged.models, metric], + ? sortModelsByTokens(detailBreakdown.models) + : detailBreakdown.models, + [breakdown, detailBreakdown.models, metric], ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const breakdownProviders = focusedProvider === null ? activeProviders : [focusedProvider]; + useEffect(() => { + if (!isPending && selectedProvider !== null && !activeProviders.includes(selectedProvider)) { + setSelectedProvider(null); + setExpandedModelKey(null); + } + }, [activeProviders, isPending, selectedProvider]); + const selectProvider = (provider: UsageProviderKind | null) => { + setSelectedProvider(provider); + setExpandedModelKey(null); + }; const selectedModel = selectedModelKey === null ? undefined @@ -211,18 +268,47 @@ export function UsagePage() { 0, ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })), ); - const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + const timeValueColumnWidth = `${60 / (breakdownProviders.length + 2)}%`; const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); + setCustomSinceValue(""); + setCustomUntilValue(""); + setCustomOriginalWindow(undefined); setWindowSelection({ + kind: "day", days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectHourWindow = (hours: (typeof QUICK_USAGE_HOUR_OPTIONS)[number]) => { + const nextWindow = makeRollingUsageWindow(hours); + setWindowSelection({ kind: "hours", hours, window: nextWindow }); + if (nextWindow.sinceTime !== undefined && nextWindow.untilTime !== undefined) { + setCustomOriginalWindow(nextWindow); + setCustomSinceValue(toLocalDateTimeValue(new Date(nextWindow.sinceTime))); + setCustomUntilValue(toLocalDateTimeValue(new Date(nextWindow.untilTime))); + } + }; + const applyCustomWindow = () => { + const validation = validateCustomUsageWindow( + customSinceValue, + customUntilValue, + undefined, + customOriginalWindow, + ); + if (!validation.ok) return; + setCustomOriginalWindow(validation.window); + setWindowSelection({ kind: "custom", window: validation.window }); + }; + const clearCustomWindow = () => { + setCustomSinceValue(""); + setCustomUntilValue(""); + selectWindow(preferences.windowDays); + }; const selectMetric = (nextMetric: UsageMetric) => { if (nextMetric === "limits") setLimitsNow(Date.now()); const nextPreferences = { metric: nextMetric, windowDays }; @@ -286,14 +372,19 @@ export function UsagePage() { }); return; } - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); - if ( + const nextWindow = + windowSelection.kind === "day" + ? makeWindow(windowDays, undefined, windowDays === 1 ? "hour" : "day") + : windowSelection.kind === "hours" + ? makeRollingUsageWindow(windowSelection.hours) + : windowSelection.window; + const windowChanged = nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || - nextWindow.untilTime !== window.untilTime - ) { - setWindowSelection({ days: windowDays, window: nextWindow }); + nextWindow.untilTime !== window.untilTime; + if (windowChanged && windowSelection.kind !== "custom") { + setWindowSelection({ ...windowSelection, window: nextWindow }); } refreshingRef.current = true; setIsRefreshing(true); @@ -320,9 +411,29 @@ export function UsagePage() { }, [showingLimits, connectedLimitsEnvironments]); const windowLabel = - isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + window.sinceTime !== undefined && window.untilTime !== undefined ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; + const desktopWindowLabel = + windowSelection.kind === "hours" + ? `Past ${windowSelection.hours}h · ${windowLabel}` + : windowSelection.kind === "custom" + ? `Custom range · ${windowLabel}` + : windowLabel; + const windowPeriodValue = windowSelection.kind === "day" ? String(windowDays) : ""; + const rangePickerProps = { + selection: windowSelection, + timeZone: window.timeZone, + sinceValue: customSinceValue, + untilValue: customUntilValue, + validation: customWindowValidation, + disabled: showingLimits, + onSinceValueChange: setCustomSinceValue, + onUntilValueChange: setCustomUntilValue, + onSelectHours: selectHourWindow, + onApplyCustom: applyCustomWindow, + onClear: clearCustomWindow, + }; const topbarContent = (
@@ -335,7 +446,10 @@ export function UsagePage() { environments={environments} selectedEnvironments={selectedEnvironments} selectedEnvironmentIds={selectedEnvironmentIds} - onSelectionChange={setSelectedEnvironmentIds} + onSelectionChange={(ids) => { + setSelectedEnvironmentIds(ids); + selectProvider(null); + }} showUsageStatus={!showingLimits} isPartial={isPartial} duplicateSources={merged.duplicateSources} @@ -346,7 +460,7 @@ export function UsagePage() { {!showingLimits ? ( - {windowLabel} + {desktopWindowLabel} ) : null}
@@ -370,7 +484,7 @@ export function UsagePage() { { const value = next[0]; @@ -383,6 +497,7 @@ export function UsagePage() { ))} +
+ {metric === "cost" ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} -
+ ); })}

- {isPast24Hours ? "Hourly" : "Daily"}{" "} + {isHourly ? "Hourly" : "Daily"}{" "} {metric === "tokens" ? "processed tokens" : "cost"}

-
-

Totals

-
- - - - - -
-
+ {detailBreakdown.providerTotals !== null ? ( + { + selectProvider(null); + if (focusedProvider !== null) + providerTriggers.current.get(focusedProvider)?.focus(); + }} + /> + ) : ( +
+

Totals

+
+ + + + + +
+
+ )} {merged.totalTokens > 0 ? (
@@ -673,8 +838,21 @@ export function UsagePage() { ) : null}
-
-

Breakdown

+
+
+

+ {focusedProvider === null + ? "Breakdown" + : `${PROVIDER_PRESENTATION[focusedProvider].label} breakdown`} +

+ selectProvider(null)} + > + All providers + +
( @@ -696,6 +874,11 @@ export function UsagePage() { ))}
+

+ {focusedProvider === null + ? "Shares are of all providers' API estimates." + : `Shares are within ${PROVIDER_PRESENTATION[focusedProvider].label}'s API estimate. The summary and chart include all providers.`} +

{breakdown === "model" ? ( @@ -718,19 +901,21 @@ export function UsagePage() { ) : ( breakdownModels.map((model, index) => { const key = `${model.provider}:${model.model}`; + const detailId = `usage-model-${encodeURIComponent(key)}`; + const expanded = expandedModelKey === key; const value = metric === "tokens" ? model.totalTokens : model.costUsd; const share = modelShare( model, metric === "tokens" ? "tokens" : "cost", ); return ( + + {expanded ? ( + + ) : null} + ); }) )} @@ -776,7 +979,7 @@ export function UsagePage() {
{index + 1} - {/* The button's overlay makes the whole row open the model. + {/* The overlay opens the model except at the token-details toggle. Focus shows as the row's hover fill, not a ring. */} +
{formatTokens(model.totalTokens)}
+
+ +
+
- {activeProviders.map((provider) => ( + {breakdownProviders.map((provider) => ( ))} @@ -784,8 +987,8 @@ export function UsagePage() { - - {activeProviders.map((provider) => ( + + {breakdownProviders.map((provider) => ( @@ -798,7 +1001,7 @@ export function UsagePage() { {breakdownPeriods.length === 0 ? ( - {activeProviders.map((provider) => ( + {breakdownProviders.map((provider) => (
{isPast24Hours ? "Hour" : "Day"}{isHourly ? "Hour" : "Day"} {PROVIDER_PRESENTATION[provider].label}
No activity in this window. @@ -815,7 +1018,7 @@ export function UsagePage() { ? formatHourShort(period.hourStart, window.timeZone) : formatDayShort(period.day)} )} + {!showingLimits ? : null} @@ -874,6 +1078,148 @@ export function UsagePage() { ); } +function UsageRangePicker({ + selection, + timeZone, + sinceValue, + untilValue, + validation, + disabled, + onSinceValueChange, + onUntilValueChange, + onSelectHours, + onApplyCustom, + onClear, +}: { + readonly selection: UsageWindowSelection; + readonly timeZone: string; + readonly sinceValue: string; + readonly untilValue: string; + readonly validation: CustomUsageWindowValidation; + readonly disabled: boolean; + readonly onSinceValueChange: (value: string) => void; + readonly onUntilValueChange: (value: string) => void; + readonly onSelectHours: (hours: (typeof QUICK_USAGE_HOUR_OPTIONS)[number]) => void; + readonly onApplyCustom: () => void; + readonly onClear: () => void; +}) { + const [open, setOpen] = useState(false); + const hasAlternateSelection = selection.kind !== "day"; + const hasRangeDraft = sinceValue !== "" || untilValue !== ""; + const validationMessage = + sinceValue !== "" && untilValue !== "" && !validation.ok ? validation.error : null; + + return ( + + + + + } + /> + +
+
+

Short ranges

+ { + const selectedHours = Number(next[0]); + if ( + QUICK_USAGE_HOUR_OPTIONS.includes( + selectedHours as (typeof QUICK_USAGE_HOUR_OPTIONS)[number], + ) + ) { + onSelectHours(selectedHours as (typeof QUICK_USAGE_HOUR_OPTIONS)[number]); + setOpen(false); + } + }} + > + {QUICK_USAGE_HOUR_OPTIONS.map((hours) => ( + + {hours}h + + ))} + +
+ +
+ +
+
+

Custom range

+

+ Times use {timeZone}. The end is exclusive. +

+
+
+ + +
+ {validationMessage ? ( +

+ {validationMessage} +

+ ) : null} +
+ + +
+
+
+ + + ); +} + const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; function CursorEnableButton({ diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000..728f1d99d --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { enumerateHourStarts } from "@t3tools/shared/usageFormat"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +describe("single-bucket usage chart", () => { + let renderer: Root; + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + renderer = createRoot(container); + }); + + afterEach(async () => { + await act(() => renderer.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each([ + { minutes: 60, metric: "tokens" as const, values: ["100", "50", "150"] }, + { minutes: 30, metric: "cost" as const, values: ["$4.00", "$2.00", "$6.00"] }, + ])( + "plots and reads the actual bucket for a $minutes-minute $metric range", + async ({ minutes, metric, values }) => { + const hourStart = "2026-09-18T12:00:00.000Z"; + const hours = enumerateHourStarts( + hourStart, + new Date(Date.parse(hourStart) + minutes * 60 * 1000).toISOString(), + ); + expect(hours).toEqual([hourStart]); + await act(() => + renderer.render( + , + ), + ); + + const svg = container.querySelector("svg")!; + const [, , width, height] = svg.getAttribute("viewBox")!.split(" ").map(Number); + const lines = [...svg.querySelectorAll('path[fill="none"]')].map((path) => { + const points = [ + ...path.getAttribute("d")!.matchAll(/(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g), + ].map(([, x, y]) => ({ x: Number(x), y: Number(y) })); + expect(points.length, "a nonzero bucket must draw a visible line").toBeGreaterThan(1); + expect(points.at(-1)!.x - points[0]!.x).toBe(width); + for (const point of points) expect(point.y).toBe(points[0]!.y); + expect(points[0]!.y).toBeLessThan(height!); + return points; + }); + expect(lines).toHaveLength(2); + expect((height! - lines[0]![0]!.y) / (height! - lines[1]![0]!.y)).toBeCloseTo(2); + for (const area of svg.querySelectorAll('path:not([fill="none"])')) { + expect(area.getAttribute("d")).toMatch(/Z$/); + } + + const plot = svg.parentElement!; + vi.spyOn(plot, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 960, 260)); + await act(() => + plot.dispatchEvent( + new MouseEvent("mousemove", { bubbles: true, clientX: 480, clientY: 60 }), + ), + ); + expect(plot.textContent).toContain("Codex"); + expect(plot.textContent).toContain("Claude Code"); + expect(plot.textContent).toContain("Total"); + for (const value of values) expect(plot.textContent).toContain(value); + }, + ); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 62772a647..ef3118f70 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -222,14 +222,16 @@ export function UsageProviderChart({ const built = providers.map((provider) => { const providerIndex = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), - ); + const points = columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })); + const first = points[0]; + const curvePoints = + points.length === 1 && first !== undefined + ? [first, { x: VIEW_WIDTH, y: first.y }] + : points; + const line = curvePath(smoothCurve(curvePoints)); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -386,8 +388,8 @@ export function UsageProviderChart({ {hoverIndex === null ? null : ( ; +}) { + const costUnknown = detail.records > 0 && detail.unpricedRecords >= detail.records; + return ( +
+
+ + + + + + + + +
+
+

+ Pricing coverage · recorded responses +

+
+ + + +
+
+

+ Recorded counters may omit token breakdowns. Zero does not establish complete coverage. API + estimates exclude unpriced responses and do not represent subscription billing. +

+
+ ); +} + +export function UsageProviderDetails({ + provider, + onClose, +}: { + readonly provider: ProviderTotals; + readonly onClose: () => void; +}) { + const presentation = PROVIDER_PRESENTATION[provider.provider]; + const Mark = presentation.mark; + return ( +
+
+

+ + {presentation.label} details +

+ +
+ +
+ ); +} + +function DetailMetric({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index a835ad4f9..fbc66452f 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,4 +1,5 @@ -import type { ModelTotals } from "@t3tools/shared/usageMerge"; +import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { mergeUsage, type MergedUsage, type ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; import { @@ -6,6 +7,7 @@ import { costPerMillionTokens, modelShare, sortModelsByTokens, + selectUsageBreakdown, } from "./usageBreakdown"; const model = ( @@ -26,6 +28,15 @@ const model = ( reasoningTokens: 0, }, records: 1, + totals: { + uncachedInputTokens: totalTokens, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + providerReportedRecords: 0, + modelPricedRecords: 1, unpricedRecords: 0, unpricedTokens: 0, costShare: 0, @@ -90,3 +101,100 @@ describe("model rates", () => { expect(costPerMillionTokens({ ...mixed, unpricedRecords: 4 })).toBeNull(); }); }); + +describe("selectUsageBreakdown", () => { + const models = [ + model("codex-a", 100, 2), + model("codex-b", 200, 3), + { ...model("claude-a", 300, 5), provider: "claude" as const }, + ]; + const merged: MergedUsage = { + ...mergeUsage([], USAGE_CONTRACT_VERSION), + costUsd: 10, + totalTokens: 600, + models, + providers: [ + { + ...models[0]!, + provider: "codex", + totalTokens: 300, + costUsd: 5, + records: 2, + sessions: 1, + tokenShare: 0.5, + }, + { ...models[2]!, provider: "claude", sessions: 1, tokenShare: 0.5 }, + ], + daily: [ + { + day: "2026-08-11", + costUsd: 10, + totalTokens: 600, + byProvider: new Map([ + ["codex", { costUsd: 5, totalTokens: 300 }], + ["claude", { costUsd: 5, totalTokens: 300 }], + ]), + }, + { + day: "2026-08-10", + costUsd: 4, + totalTokens: 40, + byProvider: new Map([["claude", { costUsd: 4, totalTokens: 40 }]]), + }, + ], + hourly: [ + { + day: "2026-08-11", + hourStart: "2026-08-11T10:00:00.000Z", + costUsd: 10, + totalTokens: 600, + byProvider: new Map([ + ["codex", { costUsd: 5, totalTokens: 300 }], + ["claude", { costUsd: 5, totalTokens: 300 }], + ]), + }, + ], + }; + + it("scopes models and time cells while preserving the accepted aggregate", () => { + const detail = selectUsageBreakdown(merged, "codex"); + expect(detail.providerTotals).toBe(merged.providers[0]); + expect(detail.models.map((entry) => [entry.model, entry.costShare])).toEqual([ + ["codex-a", 0.4], + ["codex-b", 0.6], + ]); + expect(detail.models.map((entry) => entry.tokenShare)).toEqual([1 / 3, 2 / 3]); + expect(detail.daily).toHaveLength(1); + expect(detail.daily[0]).toMatchObject({ day: "2026-08-11", costUsd: 5, totalTokens: 300 }); + expect([...detail.daily[0]!.byProvider]).toEqual([["codex", { costUsd: 5, totalTokens: 300 }]]); + expect(detail.hourly[0]).toMatchObject({ + hourStart: "2026-08-11T10:00:00.000Z", + costUsd: 5, + totalTokens: 300, + }); + expect(merged.models[0]?.costShare).toBe(0); + expect(merged.daily[0]?.costUsd).toBe(10); + expect(merged.daily[0]?.byProvider.size).toBe(2); + }); + + it("restores original model and time arrays for All providers or an absent provider", () => { + for (const provider of [null, "cursor"] as const) { + const detail = selectUsageBreakdown(merged, provider); + expect(detail.providerTotals).toBeNull(); + expect(detail.models).toBe(merged.models); + expect(detail.daily).toBe(merged.daily); + expect(detail.hourly).toBe(merged.hourly); + } + }); + + it("keeps zero-dollar model shares finite", () => { + const zeroCost = { + ...merged, + providers: merged.providers.map((entry) => ({ ...entry, costUsd: 0 })), + models: merged.models.map((entry) => ({ ...entry, costUsd: 0 })), + }; + expect(selectUsageBreakdown(zeroCost, "codex").models.map((entry) => entry.costShare)).toEqual([ + 0, 0, + ]); + }); +}); diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index ab449af9f..8f2454016 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -1,7 +1,10 @@ -import type { UsageTokenTotals } from "@t3tools/contracts"; +import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; import { isModelCostUnknown, type CategoryCost, + type DailyTotals, + type HourlyTotals, + type MergedUsage, type ModelTotals, type SpeedCost, } from "@t3tools/shared/usageMerge"; @@ -84,3 +87,40 @@ export function speedCostSegments(cost: SpeedCost): readonly ShareSegment[] { { label: "Ultrafast", value: cost.ultrafast, color: ink(100) }, ]; } + +function providerPeriods( + periods: readonly T[], + provider: UsageProviderKind, +): readonly T[] { + return periods.flatMap((period) => { + const totals = period.byProvider.get(provider); + return totals === undefined + ? [] + : [{ ...period, ...totals, byProvider: new Map([[provider, totals]]) }]; + }); +} + +/** Projects accepted merged totals; provider focus never reclaims or remerges sources. */ +export function selectUsageBreakdown(merged: MergedUsage, provider: UsageProviderKind | null) { + const providerTotals = merged.providers.find((totals) => totals.provider === provider) ?? null; + if (providerTotals === null) { + return { + providerTotals, + models: merged.models, + daily: merged.daily, + hourly: merged.hourly, + }; + } + return { + providerTotals, + models: merged.models + .filter((model) => model.provider === providerTotals.provider) + .map((model) => ({ + ...model, + costShare: providerTotals.costUsd === 0 ? 0 : model.costUsd / providerTotals.costUsd, + tokenShare: providerTotals.totalTokens === 0 ? 0 : model.totalTokens / providerTotals.totalTokens, + })), + daily: providerPeriods(merged.daily, providerTotals.provider), + hourly: providerPeriods(merged.hourly, providerTotals.provider), + }; +} diff --git a/apps/web/src/components/usage/usageDateRange.test.ts b/apps/web/src/components/usage/usageDateRange.test.ts new file mode 100644 index 000000000..f696cb55c --- /dev/null +++ b/apps/web/src/components/usage/usageDateRange.test.ts @@ -0,0 +1,168 @@ +// @effect-diagnostics globalDate:off -- Date-time local inputs intentionally use the viewer's zone. +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeRollingUsageWindow, + toLocalDateTimeValue, + validateCustomUsageWindow, +} from "./usageDateRange"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("usage date ranges", () => { + it.each([1, 3, 6, 12])("builds an exact %ih rolling window", (hours) => { + vi.stubEnv("TZ", "UTC"); + const window = makeRollingUsageWindow(hours, new Date("2026-09-18T12:37:42.123Z")); + + expect(window.resolution).toBe("hour"); + expect(window.sinceTime).toBe(`2026-09-18T${String(12 - hours).padStart(2, "0")}:37:00.000Z`); + expect(window.untilTime).toBe("2026-09-18T12:37:00.000Z"); + }); + + it("preserves exact boundaries and switches to daily buckets for longer ranges", () => { + vi.stubEnv("TZ", "UTC"); + const validation = validateCustomUsageWindow( + "2026-09-15T10:30", + "2026-09-18T12:37", + new Date("2026-09-18T12:38:00.000Z"), + ); + + expect(validation).toMatchObject({ + ok: true, + window: { + sinceDay: "2026-09-15", + untilDay: "2026-09-18", + timeZone: "UTC", + resolution: "exactDay", + sinceTime: "2026-09-15T10:30:00.000Z", + untilTime: "2026-09-18T12:37:00.000Z", + }, + }); + }); + + it("uses the viewer's zone to build exact hourly bounds across DST", () => { + vi.stubEnv("TZ", "America/New_York"); + const validation = validateCustomUsageWindow( + "2026-03-08T01:00", + "2026-03-08T04:00", + new Date("2026-03-08T09:00:00.000Z"), + ); + + expect(validation).toMatchObject({ + ok: true, + window: { + sinceDay: "2026-03-08", + untilDay: "2026-03-08", + timeZone: "America/New_York", + resolution: "hour", + sinceTime: "2026-03-08T06:00:00.000Z", + untilTime: "2026-03-08T08:00:00.000Z", + }, + }); + }); + + it("preserves both exact instants of an unchanged repeated-hour prefill", () => { + vi.stubEnv("TZ", "America/New_York"); + const now = new Date("2026-11-01T06:30:00.000Z"); + const original = makeRollingUsageWindow(1, now); + const validation = validateCustomUsageWindow( + toLocalDateTimeValue(new Date(original.sinceTime!)), + toLocalDateTimeValue(new Date(original.untilTime!)), + now, + original, + ); + + expect(validation).toEqual({ ok: true, window: original }); + }); + + it.each([ + { + sinceValue: "2026-11-01T00:30", + untilValue: "2026-11-01T01:30", + sinceTime: "2026-11-01T04:30:00.000Z", + untilTime: "2026-11-01T06:30:00.000Z", + }, + { + sinceValue: "2026-11-01T01:30", + untilValue: "2026-11-01T02:30", + sinceTime: "2026-11-01T05:30:00.000Z", + untilTime: "2026-11-01T07:30:00.000Z", + }, + ])( + "preserves the unchanged bound while editing the other to $sinceValue / $untilValue", + ({ sinceValue, untilValue, sinceTime, untilTime }) => { + vi.stubEnv("TZ", "America/New_York"); + const original = makeRollingUsageWindow(1, new Date("2026-11-01T06:30:00.000Z")); + + expect( + validateCustomUsageWindow( + sinceValue, + untilValue, + new Date("2026-11-01T08:00:00.000Z"), + original, + ), + ).toMatchObject({ ok: true, window: { sinceTime, untilTime } }); + }, + ); + + it.each([ + { sinceValue: "2026-11-01T01:15", untilValue: "2026-11-01T01:30" }, + { sinceValue: "2026-11-01T01:30", untilValue: "2026-11-01T01:45" }, + ])("rejects an ambiguous edit to $sinceValue / $untilValue", ({ sinceValue, untilValue }) => { + vi.stubEnv("TZ", "America/New_York"); + const now = new Date("2026-11-01T06:30:00.000Z"); + const original = makeRollingUsageWindow(1, now); + + expect(validateCustomUsageWindow(sinceValue, untilValue, now, original)).toMatchObject({ + ok: false, + error: + "This local time occurs twice when clocks move back. Choose a time outside the repeated hour or keep the original range time unchanged.", + }); + }); + + it("detects a repeated local time when the clock moves back by half an hour", () => { + vi.stubEnv("TZ", "Australia/Lord_Howe"); + + expect( + validateCustomUsageWindow( + "2026-04-05T01:45", + "2026-04-05T02:30", + new Date("2026-04-05T00:00:00.000Z"), + ), + ).toMatchObject({ + ok: false, + error: + "This local time occurs twice when clocks move back. Choose a time outside the repeated hour or keep the original range time unchanged.", + }); + }); + + it("rejects missing, reversed, and future ranges", () => { + vi.stubEnv("TZ", "UTC"); + const now = new Date("2026-09-18T12:00:00.000Z"); + + expect(validateCustomUsageWindow("", "2026-09-18T11:00", now)).toMatchObject({ + ok: false, + error: "Enter both a start and end date and time.", + }); + expect(validateCustomUsageWindow("2026-09-18T11:00", "2026-09-18T10:00", now)).toMatchObject({ + ok: false, + error: "End date and time must be after the start.", + }); + expect(validateCustomUsageWindow("2026-09-18T11:00", "2026-09-18T12:01", now)).toMatchObject({ + ok: false, + error: "End date and time cannot be in the future.", + }); + }); + + it("rejects nonexistent local times and formats an existing local value", () => { + vi.stubEnv("TZ", "America/New_York"); + + expect( + validateCustomUsageWindow("2026-03-08T02:30", "2026-03-08T03:30", new Date()), + ).toMatchObject({ + ok: false, + error: "Enter valid local dates and times.", + }); + expect(toLocalDateTimeValue(new Date("2026-09-18T12:37:00.000Z"))).toBe("2026-09-18T08:37"); + }); +}); diff --git a/apps/web/src/components/usage/usageDateRange.ts b/apps/web/src/components/usage/usageDateRange.ts new file mode 100644 index 000000000..55042686a --- /dev/null +++ b/apps/web/src/components/usage/usageDateRange.ts @@ -0,0 +1,110 @@ +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; + +const HOUR_MS = 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; +const LOCAL_DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + +export type CustomUsageWindowValidation = + | { readonly ok: true; readonly window: UsageSummaryInput } + | { readonly ok: false; readonly error: string }; + +export function toLocalDateTimeValue(date: Date): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function parseLocalDateTime( + value: string, + originalTime?: string, +): { readonly date: Date; readonly ambiguous: boolean } | null { + if (!LOCAL_DATE_TIME_PATTERN.test(value)) return null; + if (originalTime !== undefined) { + const original = new Date(originalTime); + if (!Number.isNaN(original.getTime()) && toLocalDateTimeValue(original) === value) { + return { date: original, ambiguous: false }; + } + } + const date = new Date(value); + if (Number.isNaN(date.getTime()) || toLocalDateTimeValue(date) !== value) return null; + const offset = date.getTimezoneOffset(); + const ambiguous = [-24, 24].some((hours) => { + const neighboringOffset = new Date(date.getTime() + hours * HOUR_MS).getTimezoneOffset(); + if (neighboringOffset === offset) return false; + const alternative = new Date(date.getTime() + (neighboringOffset - offset) * MINUTE_MS); + return toLocalDateTimeValue(alternative) === value; + }); + return { date, ambiguous }; +} + +function exactWindow(since: Date, until: Date, resolution: "exactDay" | "hour"): UsageSummaryInput { + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } + return { + sinceDay: UsageDay.make(format.format(since)), + untilDay: UsageDay.make(format.format(until)), + timeZone, + resolution, + sinceTime: since.toISOString(), + untilTime: until.toISOString(), + }; +} + +export function makeRollingUsageWindow(hours: number, now = new Date()): UsageSummaryInput { + if (!Number.isInteger(hours) || hours < 1 || hours > 24) { + throw new RangeError("A rolling usage window must be from 1 to 24 whole hours"); + } + const untilMs = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; + const until = new Date(untilMs); + const since = new Date(untilMs - hours * HOUR_MS); + return exactWindow(since, until, "hour"); +} + +export function validateCustomUsageWindow( + sinceValue: string, + untilValue: string, + now = new Date(), + originalBounds?: Pick, +): CustomUsageWindowValidation { + if (!sinceValue || !untilValue) { + return { ok: false, error: "Enter both a start and end date and time." }; + } + const sinceResult = parseLocalDateTime(sinceValue, originalBounds?.sinceTime); + const untilResult = parseLocalDateTime(untilValue, originalBounds?.untilTime); + if (sinceResult === null || untilResult === null) { + return { ok: false, error: "Enter valid local dates and times." }; + } + if (sinceResult.ambiguous || untilResult.ambiguous) { + return { + ok: false, + error: + "This local time occurs twice when clocks move back. Choose a time outside the repeated hour or keep the original range time unchanged.", + }; + } + const since = sinceResult.date; + const until = untilResult.date; + if (until.getTime() <= since.getTime()) { + return { ok: false, error: "End date and time must be after the start." }; + } + if (until.getTime() > now.getTime()) { + return { ok: false, error: "End date and time cannot be in the future." }; + } + + const resolution = until.getTime() - since.getTime() <= 24 * HOUR_MS ? "hour" : "exactDay"; + return { ok: true, window: exactWindow(since, until, resolution) }; +} diff --git a/apps/web/src/state/tokenAccounting.test.ts b/apps/web/src/state/tokenAccounting.test.ts new file mode 100644 index 000000000..004ffe0c8 --- /dev/null +++ b/apps/web/src/state/tokenAccounting.test.ts @@ -0,0 +1,90 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + accountingUnavailableMessage, + formatAccountingCount, + formatAccountingMetric, + selectAccountingEnvironment, + type AccountingEnvironment, +} from "./tokenAccounting"; + +function environment( + id: string, + overrides: Partial = {}, +): AccountingEnvironment { + return { + environmentId: EnvironmentId.make(id), + label: id, + primary: false, + connected: true, + supported: true, + ...overrides, + }; +} + +describe("saved accounting environment selection", () => { + it("prefers a capable connected primary and honors an explicit eligible selection", () => { + const primary = environment("primary", { primary: true }); + const remote = environment("remote"); + expect(selectAccountingEnvironment([remote, primary], null)).toBe(primary); + expect(selectAccountingEnvironment([remote, primary], remote.environmentId)).toBe(remote); + }); + + it("uses the sole eligible environment and requires a choice when several have no primary", () => { + const first = environment("first"); + const second = environment("second"); + expect(selectAccountingEnvironment([first], null)).toBe(first); + expect(selectAccountingEnvironment([first, second], null)).toBeNull(); + expect(selectAccountingEnvironment([first, second], second.environmentId)).toBe(second); + }); + + it("excludes disconnected and unsupported environments even when selected or primary", () => { + const disconnected = environment("disconnected", { primary: true, connected: false }); + const old = environment("old", { supported: false }); + const eligible = environment("eligible"); + expect(selectAccountingEnvironment([disconnected, old], disconnected.environmentId)).toBeNull(); + expect(selectAccountingEnvironment([disconnected, old, eligible], old.environmentId)).toBe( + eligible, + ); + }); +}); + +describe("saved accounting missingness", () => { + it("distinguishes known zero, partial known zero and an entirely unknown metric", () => { + expect( + formatAccountingMetric({ total: 0, known_sum: 0, known_requests: 1, missing_requests: 0 }), + ).toBe("0"); + expect( + formatAccountingMetric({ total: null, known_sum: 0, known_requests: 1, missing_requests: 1 }), + ).toBe("Known: 0 · total unknown"); + expect( + formatAccountingMetric({ + total: null, + known_sum: null, + known_requests: 0, + missing_requests: 2, + }), + ).toBe("Unknown"); + expect( + formatAccountingMetric({ + total: null, + known_sum: 42, + known_requests: 1, + missing_requests: 1, + }), + ).toBe("Known: 42 · total unknown"); + }); + + it("formats large counts without claiming additional precision", () => { + expect(formatAccountingCount(5_850_000)).toBe("5.85M"); + expect(formatAccountingCount(30_500_000_000)).toBe("30.5B"); + expect(formatAccountingCount(400_000_000)).toBe("400M"); + }); + + it("uses a fixed message for a typed identity failure", () => { + expect( + accountingUnavailableMessage({ status: "invalid", reason: "report_identity_mismatch" }), + ).toBe("The saved report did not pass its identity check."); + }); +}); diff --git a/apps/web/src/state/tokenAccounting.ts b/apps/web/src/state/tokenAccounting.ts new file mode 100644 index 000000000..0005c9d32 --- /dev/null +++ b/apps/web/src/state/tokenAccounting.ts @@ -0,0 +1,128 @@ +import type { + EnvironmentId, + TokenAccountingMetric, + TokenAccountingReadInput, + TokenAccountingReadResult, + TokenAccountingUnavailable, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { useLayoutEffect, useRef, useState } from "react"; + +export interface AccountingEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly primary: boolean; + readonly connected: boolean; + readonly supported: boolean; +} + +export function selectAccountingEnvironment( + environments: readonly AccountingEnvironment[], + selected: EnvironmentId | null, +): AccountingEnvironment | null { + const eligible = environments.filter( + (environment) => environment.connected && environment.supported, + ); + return ( + eligible.find((environment) => environment.environmentId === selected) ?? + eligible.find((environment) => environment.primary) ?? + (eligible.length === 1 ? eligible[0]! : null) + ); +} + +export function formatAccountingCount(value: number): string { + if (value >= 1e9) + return `${(value / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`; + if (value >= 1e6) + return `${(value / 1e6).toLocaleString("en-US", { maximumFractionDigits: 2 })}M`; + return value.toLocaleString("en-US"); +} + +export function formatAccountingMetric(metric: TokenAccountingMetric): string { + if (metric.total !== null) return formatAccountingCount(metric.total); + if (metric.known_sum !== null) + return `Known: ${formatAccountingCount(metric.known_sum)} · total unknown`; + return "Unknown"; +} + +export const ACCOUNTING_TRANSPORT_ERROR = + "The saved report could not be read. Try again when connected."; + +const UNAVAILABLE_MESSAGES: Record = { + reader_unconfigured: "A saved accounting reader has not been configured for this environment.", + report_unconfigured: "A saved accounting report has not been selected for this environment.", + host_binding_unverified: + "The saved accounting reader is unavailable until its host binding is verified.", + configured_report_missing: "The configured saved report is missing.", + report_invalid: "The saved report did not pass validation.", + report_identity_mismatch: "The saved report did not pass its identity check.", + configured_report_id_mismatch: "The saved report does not match the configured report ID.", + projection_invalid: "The saved report could not be represented as a valid accounting summary.", + report_schema_unsupported: "This saved report format is not supported.", + identity_algorithm_unsupported: "This saved report identity format is not supported.", + input_too_large: "The saved report exceeds the reader size limit.", + projection_too_large: "The saved accounting summary exceeds the response size limit.", + collection_limit_exceeded: "The saved report exceeds the supported collection limits.", + reader_timeout: "The saved accounting reader did not finish in time.", + reader_failed: "The saved accounting reader could not read the report.", +}; + +export function accountingUnavailableMessage(result: TokenAccountingUnavailable): string { + return UNAVAILABLE_MESSAGES[result.reason]; +} + +export interface AccountingReadTarget { + readonly environmentId: EnvironmentId; + readonly generation: number; +} + +type AccountingRead = (target: { + readonly environmentId: EnvironmentId; + readonly input: TokenAccountingReadInput; +}) => Promise>; + +type AccountingReadState = + | { readonly phase: "idle" | "reading" | "error" } + | { readonly phase: "observed"; readonly result: TokenAccountingReadResult }; + +export function useSavedTokenAccounting(target: AccountingReadTarget | null, read: AccountingRead) { + const key = target === null ? null : `${target.environmentId}:${target.generation}`; + const [state, setState] = useState({ phase: "idle" }); + const lifetime = useRef({ key: null as string | null, attempt: 0, reading: false }); + + useLayoutEffect(() => { + lifetime.current.key = key; + lifetime.current.attempt += 1; + lifetime.current.reading = false; + setState({ phase: "idle" }); + return () => { + // A result belongs to one connection generation and one mounted panel. + lifetime.current.key = null; + lifetime.current.attempt += 1; + lifetime.current.reading = false; + }; + }, [key]); + + async function load() { + if (target === null || lifetime.current.key !== key || lifetime.current.reading) return; + const attempt = ++lifetime.current.attempt; + lifetime.current.reading = true; + setState({ phase: "reading" }); + const current = () => lifetime.current.key === key && lifetime.current.attempt === attempt; + try { + const result = await read({ environmentId: target.environmentId, input: {} }); + if (!current()) return; + setState( + result._tag === "Success" + ? { phase: "observed", result: result.value } + : { phase: "error" }, + ); + } catch { + if (current()) setState({ phase: "error" }); + } finally { + if (current()) lifetime.current.reading = false; + } + } + + return { state, load }; +} diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 8dda4440e..f97ca38e7 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -34,6 +34,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { + EnvironmentRpcUnavailableError, EnvironmentRpcRequestObserver, request, runStream, @@ -309,6 +310,122 @@ describe("environment RPC", () => { }), ); + it.effect.each(["validation failure", "disconnected"] as const)( + "keeps %s local without observing, dispatching, or retrying a request", + (reason) => + Effect.gen(function* () { + let validations = 0; + let requests = 0; + let observations = 0; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.sync(() => { + requests += 1; + return { status: "available", version: "2026.6.0" }; + }), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + if (reason === "validation failure") { + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + } + + const failure = yield* request( + WS_METHODS.cloudGetRelayClientStatus, + {}, + { + validateSession: () => + Effect.sync(() => { + validations += 1; + }).pipe(Effect.andThen(Effect.fail("validation failed"))), + }, + ).pipe( + Effect.flip, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: () => + Effect.sync(() => { + observations += 1; + return Effect.void; + }), + }), + ), + ); + + if (reason === "validation failure") { + expect(failure).toBe("validation failed"); + expect(validations).toBe(1); + } else { + expect(failure).toBeInstanceOf(EnvironmentRpcUnavailableError); + expect(validations).toBe(0); + } + expect(requests).toBe(0); + expect(observations).toBe(0); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect.each(["failure", "interruption"] as const)( + "finalizes a validated unary request observation after %s without retrying", + (reason) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const observations: string[] = []; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.andThen( + Effect.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }), + ), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const requestFiber = yield* request( + WS_METHODS.cloudGetRelayClientStatus, + {}, + { + validateSession: () => Effect.void, + }, + ).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: () => + Effect.sync(() => { + observations.push("start"); + return Effect.sync(() => { + observations.push("finish"); + }); + }), + }), + ), + Effect.forkChild, + ); + yield* Deferred.await(started); + expect(observations).toEqual(["start"]); + if (reason === "failure") { + yield* Deferred.succeed(finish, undefined); + expect(Exit.isFailure(yield* Fiber.await(requestFiber))).toBe(true); + } else { + yield* Fiber.interrupt(requestFiber); + } + expect(observations).toEqual(["start", "finish"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + it.effect("binds finite streaming commands to one active session", () => Effect.gen(function* () { const firstEvents = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4ce45cf3f..632f777c7 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -150,13 +150,24 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer export const request = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { + E = never, + R = never, +>( + tag: TTag, + input: EnvironmentRpcInput, + options?: { + readonly validateSession: (session: RpcSession) => Effect.Effect; + }, +) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; yield* Effect.annotateCurrentSpan({ "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); const session = yield* currentSession(); + if (options !== undefined) { + yield* options.validateSession(session); + } const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index ef72946f5..29bdda49d 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -705,7 +705,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily< }); } -export function createEnvironmentRpcCommand( +export function createEnvironmentRpcCommand( runtime: Atom.AtomRuntime, options: { readonly label: string; @@ -714,7 +714,7 @@ export function createEnvironmentRpcCommand, ) => Effect.Effect< EnvironmentRpcSuccess, - EnvironmentRpcFailure | EnvironmentRpcUnavailableError, + EnvironmentRpcFailure | EnvironmentRpcUnavailableError | E, EnvironmentSupervisor.EnvironmentSupervisor | EnvironmentRegistry.EnvironmentRegistry >; readonly scheduler?: AtomCommandScheduler; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5f1818399..8a0bad0f7 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -40,6 +40,7 @@ import * as Persistence from "../platform/persistence.ts"; import { runCachePersistence } from "./cachePersistence.ts"; import { isRpcClientError, + EnvironmentRpcUnavailableError, request, runStream, subscribe, @@ -1107,6 +1108,27 @@ export function createServerEnvironmentAtoms( staleTimeMs: 0, idleTtlMs: 0, }), + readTokenAccounting: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:read-token-accounting", + tag: WS_METHODS.serverReadTokenAccounting, + execute: (input) => + request(WS_METHODS.serverReadTokenAccounting, input, { + validateSession: (session) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const config = yield* session.initialConfig; + if (config.environment.capabilities.savedTokenAccounting !== true) { + return yield* Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: "This environment does not advertise a saved accounting reader.", + }), + ); + } + }), + }), + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), configProjection, welcome, legacyThreadMigration: createEnvironmentRpcSubscriptionAtomFamily(runtime, { diff --git a/packages/client-runtime/src/state/serverTokenAccounting.test.ts b/packages/client-runtime/src/state/serverTokenAccounting.test.ts new file mode 100644 index 000000000..b92c01f8a --- /dev/null +++ b/packages/client-runtime/src/state/serverTokenAccounting.test.ts @@ -0,0 +1,251 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type ServerConfig, + type TokenAccountingReadResult, + WS_METHODS, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +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 Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createServerEnvironmentAtoms } from "./server.ts"; + +const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("accounting-environment"), + label: "Accounting environment", + httpBaseUrl: "https://accounting.example.test", + wsBaseUrl: "wss://accounting.example.test", +}); +const result: TokenAccountingReadResult = { + state: "unavailable", + status: "missing", + reason: "configured_report_missing", + configuredReportId: "a".repeat(64), + readAt: "2026-10-02T12:00:00Z", +}; + +const makeHarness = Effect.fn("ServerTokenAccountingTest.makeHarness")(function* ( + supported = true, + connected = true, +) { + const config = { + settings: DEFAULT_SERVER_SETTINGS, + environment: { + serverVersion: "0.0.1", + capabilities: supported ? { savedTokenAccounting: true } : {}, + }, + } as ServerConfig; + let reads = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.make({ version: 1, type: "snapshot", config }), + [WS_METHODS.serverReadTokenAccounting]: () => + Effect.sync(() => { + reads += 1; + return result; + }), + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.succeed(config), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const sessionRef = yield* SubscriptionRef.make( + connected ? Option.some(session) : Option.none(), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: connected ? "connected" : "available", + }), + session: sessionRef, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const cache = EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + Layer.succeed(EnvironmentCacheStore, cache), + ), + ); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => Atom.make(config), + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + return { atoms, registry, sessionRef, session, config, reads: () => reads }; +}); + +it.effect("reads saved accounting only after an explicit command and rereads explicitly", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + expect(harness.reads()).toBe(0); + const first = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(first._tag).toBe("Success"); + if (first._tag === "Success") expect(first.value).toEqual(result); + expect(harness.reads()).toBe(1); + yield* SubscriptionRef.set(harness.sessionRef, Option.none()); + yield* SubscriptionRef.set(harness.sessionRef, Option.some(harness.session)); + expect(harness.reads()).toBe(1); + yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(harness.reads()).toBe(2); + }), + ), +); + +it.effect("does not dispatch to a server that omits the optional reader capability", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(false); + const read = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(read._tag).toBe("Failure"); + expect(harness.reads()).toBe(0); + }), + ), +); + +it.effect("keeps saved accounting validation and dispatch on the same session", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + const validationStarted = yield* Deferred.make(); + const finishValidation = yield* Deferred.make(); + const validatedSession: RpcSession = { + ...harness.session, + initialConfig: Effect.gen(function* () { + yield* Deferred.succeed(validationStarted, undefined); + yield* Deferred.await(finishValidation); + return harness.config; + }), + }; + let replacementReads = 0; + const replacementConfig: ServerConfig = { + ...harness.config, + environment: { + ...harness.config.environment, + capabilities: { + ...harness.config.environment.capabilities, + savedTokenAccounting: false, + }, + }, + }; + const replacementSession: RpcSession = { + ...harness.session, + initialConfig: Effect.succeed(replacementConfig), + client: { + [WS_METHODS.serverReadTokenAccounting]: () => + Effect.sync(() => { + replacementReads += 1; + return result; + }), + } as unknown as WsRpcProtocolClient, + }; + yield* SubscriptionRef.set(harness.sessionRef, Option.some(validatedSession)); + const readFiber = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ).pipe(Effect.forkChild); + yield* Effect.raceFirst( + Deferred.await(validationStarted), + Fiber.join(readFiber).pipe( + Effect.flatMap((read) => + Effect.die( + new Error( + read._tag === "Failure" + ? Cause.pretty(read.cause) + : "Saved accounting completed before capability validation.", + ), + ), + ), + ), + ); + yield* SubscriptionRef.set(harness.sessionRef, Option.some(replacementSession)); + yield* Deferred.succeed(finishValidation, undefined); + const read = yield* Fiber.join(readFiber); + + expect(replacementReads).toBe(0); + expect(harness.reads()).toBe(1); + expect(read._tag).toBe("Success"); + if (read._tag === "Success") expect(read.value).toEqual(result); + }), + ), +); + +it.effect("keeps a disconnected reader failure local without dispatch", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(true, false); + const read = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(read._tag).toBe("Failure"); + expect(harness.reads()).toBe(0); + }), + ), +); diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3f624adf6..93433d5db 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -14,6 +14,16 @@ const descriptor = { } as const; describe("ExecutionEnvironmentDescriptor", () => { + it("omits saved accounting on older servers and preserves explicit support", () => { + expect(decodeDescriptor(descriptor).capabilities.savedTokenAccounting).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, savedTokenAccounting: true }, + }).capabilities.savedTokenAccounting, + ).toBe(true); + }); + it("requires an advertised required-worktree bootstrap capability", () => { expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined(); expect( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9a31f0888..cef6eba99 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -136,6 +136,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ usageLimitSources: Schema.optionalKey(Schema.Boolean), /** Server persists custom model rates and applies them to usage summaries. */ usagePriceOverrides: Schema.optionalKey(Schema.Boolean), + /** An enrolled adapter can explicitly read one configured, canonically validated saved report. */ + savedTokenAccounting: Schema.optionalKey(Schema.Boolean), /** Server persists model mappings and folds mapped usage into the target model. */ usageModelAliases: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin / thread.unpin commands. Same diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8690bb1b2..c89488e83 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -55,6 +55,7 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./tokenAccounting.ts"; export * from "./scheduledTask.ts"; export * from "./worktreeMcp.ts"; export * from "./resourceTelemetry.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9fb0f1867..c85dca79e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -302,6 +302,7 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; +import { TokenAccountingReadInput, TokenAccountingReadResult } from "./tokenAccounting.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { ScheduledTaskDeleteInput, @@ -465,6 +466,7 @@ export const WS_METHODS = { serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + serverReadTokenAccounting: "server.readTokenAccounting", serverRefreshUsageRates: "server.refreshUsageRates", // Scheduled tasks @@ -836,6 +838,12 @@ const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); +const WsServerReadTokenAccountingRpc = Rpc.make(WS_METHODS.serverReadTokenAccounting, { + payload: TokenAccountingReadInput, + success: TokenAccountingReadResult, + error: EnvironmentAuthorizationError, +}); + /** * Refetches the model rate table ahead of its daily TTL, so a model released * since the last fetch gets priced. The next usage summary uses the new table. @@ -1745,6 +1753,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, + WsServerReadTokenAccountingRpc, WsServerRefreshUsageRatesRpc, WsServerSignalProcessRpc, WsScheduledTasksListRpc, diff --git a/packages/contracts/src/tokenAccounting.test.ts b/packages/contracts/src/tokenAccounting.test.ts new file mode 100644 index 000000000..f5ba659b1 --- /dev/null +++ b/packages/contracts/src/tokenAccounting.test.ts @@ -0,0 +1,219 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + TokenAccountingMetric, + TokenAccountingReadInput, + TokenAccountingReadResult, + TokenAccountingReport, +} from "./tokenAccounting.ts"; + +describe("saved token accounting projection", () => { + it("preserves a known sum independently from an unknown complete total", () => { + const metric = { known_sum: 42, total: null, known_requests: 1, missing_requests: 1 }; + expect(Schema.decodeUnknownSync(TokenAccountingMetric)(metric)).toEqual(metric); + }); + + it("preserves entirely unknown metrics instead of normalizing them to zero", () => { + const metric = { known_sum: null, total: null, known_requests: 0, missing_requests: 2 }; + expect(Schema.decodeUnknownSync(TokenAccountingMetric)(metric)).toEqual(metric); + }); + + it("rejects unsafe counts and undeclared metric fields", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingMetric); + const metric = { known_sum: null, total: null, known_requests: 0, missing_requests: 2 }; + expect(() => decode({ ...metric, known_sum: Number.MAX_SAFE_INTEGER + 1 })).toThrow(); + expect(() => decode({ ...metric, complete: true })).toThrow(); + }); + + it("rejects an original report presented as the consumer projection", () => { + expect(() => + Schema.decodeUnknownSync(TokenAccountingReport)({ + schema: "programmatic-token-info.accounting-report/v1", + report_id: "a".repeat(64), + }), + ).toThrow(); + }); + + it("rejects caller paths, report selectors and execution flags", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReadInput); + expect(decode({})).toEqual({}); + for (const input of [{ path: "saved.json" }, { reportId: "a".repeat(64) }, { args: [] }]) { + expect(() => decode(input)).toThrow(); + } + }); + + it("keeps status and reason paired and rejects diagnostic fields", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReadResult); + const result = { + state: "unavailable", + status: "missing", + reason: "configured_report_missing", + configuredReportId: "a".repeat(64), + readAt: "2026-10-02T12:00:00Z", + }; + expect(decode(result)).toEqual(result); + expect(() => decode({ ...result, status: "unsupported" })).toThrow(); + expect(() => decode({ ...result, stderr: "synthetic diagnostic" })).toThrow(); + expect(() => decode({ ...result, configuredReportId: "A".repeat(64) })).toThrow(); + }); + + it("accepts native allocation pairs and rejects impossible pairs in either direction", () => { + const decodeInput = Schema.decodeUnknownSync(TokenAccountingReport.fields.input.fields.groups); + const decodeOutput = Schema.decodeUnknownSync( + TokenAccountingReport.fields.output.fields.groups, + ); + const group = (name: string, subtype: string) => ({ + group: name, + subtype, + basis: "measured_component", + central: 0, + low: 0, + high: 0, + }); + const input = [ + group("guidance", "system"), + group("tool_result", "file_read_code"), + group("assistant_history", "tool_call_arguments"), + group("tool_schema_config", "unknown"), + ]; + const output = [ + group("assistant_text", "final"), + group("tool_call_arguments", "agent_launch"), + group("other_generated", "unknown"), + ]; + expect(decodeInput(input)).toEqual(input); + expect(decodeOutput(output)).toEqual(output); + for (const invalid of [ + group("guidance", "file_writing"), + group("tool_result", "system"), + group("user_role_message", "session_task"), + group("assistant_text", "commentary"), + group("reasoning", "unknown"), + group("unknown", "unknown"), + ]) + expect(() => decodeInput([invalid])).toThrow(); + for (const invalid of [ + group("guidance", "system"), + group("assistant_text", "execution"), + group("tool_call_arguments", "system"), + group("other_generated", "final"), + group("reasoning", "unknown"), + group("unknown", "unknown"), + ]) + expect(() => decodeOutput([invalid])).toThrow(); + }); + + it("requires history scope and indexed selection together while preserving their absence", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReport.fields.provider_coverage); + const coverage = { + selected_requests: 0, + accounting_status_counts: { + primary: 0, + legacy_unresolved: 0, + conflict: 0, + aggregate_delta: 0, + }, + token_missingness: { + input_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + cache_write_5m_tokens: 0, + cache_write_1h_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + }, + latest_scan: { + captured_at: null, + selected_files: null, + refreshed_selected_files: null, + partial_selected_files: null, + root_scope_ids: [], + mtime_cutoff: null, + receipts_status_counts: null, + validated_files: null, + }, + cumulative_index: { + tracked_files: null, + retained_not_refreshed_files: null, + missing_tracked_files: null, + }, + freshness: { + status: "scan_unavailable", + requested_end: null, + last_scan_captured_at: null, + selected_validation_min: null, + selected_validation_max: null, + unrefreshed_files: null, + }, + historical_window_completeness: "not_proven", + }; + const selection = { + requested_thread_count: 2, + indexed_thread_count: 1, + status: "partially_indexed", + }; + const scoped = { + ...coverage, + history_scope: "all_indexed_history", + indexed_history_selection: selection, + }; + expect(decode(coverage)).toEqual(coverage); + expect(decode(scoped)).toEqual(scoped); + expect(() => decode({ ...coverage, history_scope: "all_indexed_history" })).toThrow(); + expect(() => decode({ ...coverage, indexed_history_selection: selection })).toThrow(); + }); + + it("requires real UTC calendar timestamps including Gregorian leap-year boundaries", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReadResult); + const observation = { + state: "unavailable", + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }; + for (const readAt of [ + "2024-02-29T23:59:59.123456Z", + "2000-02-29T00:00:00Z", + "2026-10-02T12:00:00.123Z", + ]) { + expect(decode({ ...observation, readAt })).toEqual({ ...observation, readAt }); + } + for (const readAt of [ + "1900-02-29T00:00:00Z", + "2026-02-29T00:00:00Z", + "2026-04-31T00:00:00Z", + "2026-00-01T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-01-00T00:00:00Z", + "0000-01-01T00:00:00Z", + "2026-01-01T24:00:00Z", + "2026-01-01T00:60:00Z", + "2026-01-01T00:00:60Z", + "2026-01-01T00:00:00.1234567Z", + "2026-01-01T00:00:00+00:00", + ]) + expect(() => decode({ ...observation, readAt })).toThrow(); + }); + + it("requires an ordered finite window at microsecond precision and preserves indexed history", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReport.fields.window); + const finite = { + start: "2026-10-02T12:00:00.100001Z", + end: "2026-10-02T12:00:00.100002Z", + end_exclusive: true, + }; + expect(decode(finite)).toEqual(finite); + const history = { scope: "all_indexed_history", start: null, end: null, end_exclusive: false }; + expect(decode(history)).toEqual(history); + for (const invalid of [ + { ...finite, start: finite.end, end: finite.start }, + { ...finite, end: finite.start }, + { ...finite, start: "2026-10-02T12:00:00.1Z", end: "2026-10-02T12:00:00.100000Z" }, + { ...finite, start: "2026-10-02T12:00:00Z", end: "2026-10-02T12:00:00.000000Z" }, + { ...finite, start: null }, + { ...finite, end: "2026-02-30T12:00:00Z" }, + ]) + expect(() => decode(invalid)).toThrow(); + }); +}); diff --git a/packages/contracts/src/tokenAccounting.ts b/packages/contracts/src/tokenAccounting.ts new file mode 100644 index 000000000..a6ac2061f --- /dev/null +++ b/packages/contracts/src/tokenAccounting.ts @@ -0,0 +1,517 @@ +import * as Schema from "effect/Schema"; + +export const TOKEN_ACCOUNTING_MAX_INPUT_BYTES = 2 * 1024 * 1024; +export const TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES = 256 * 1024; +export const TOKEN_ACCOUNTING_MAX_GROUPS = 128; +export const TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION = 256; +export const TOKEN_ACCOUNTING_PROJECTION_SCHEMA = "jones-code.token-accounting-projection/v1"; +export const TOKEN_ACCOUNTING_REPORT_SCHEMA = "programmatic-token-info.accounting-report/v1"; +export const TOKEN_ACCOUNTING_IDENTITY_ALGORITHM = "programmatic-token-info.binary64-tree/v1"; + +// Input-side checks see undeclared keys that Struct removes from its decoded output. +const closed = (schema: Schema.Struct) => + Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter( + (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Reflect.ownKeys(value).every((key) => Object.hasOwn(schema.fields, key)), + ), + ), + ); +const Count = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })); +const TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,6})?Z$/; +function isCalendarUtcTimestamp(value: string): boolean { + const parts = TIMESTAMP_PATTERN.exec(value); + if (parts === null) return false; + const year = Number(parts[1]); + const month = Number(parts[2]); + const day = Number(parts[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + year >= 1 && + month >= 1 && + month <= 12 && + day >= 1 && + day <= (daysInMonth[month - 1] ?? 0) && + Number(parts[4]) <= 23 && + Number(parts[5]) <= 59 && + Number(parts[6]) <= 59 + ); +} +const Timestamp = Schema.String.check( + Schema.isMaxLength(32), + Schema.makeFilter(isCalendarUtcTimestamp), +); +const comparableTimestamp = (value: string): string => + value.replace( + /(?:\.(\d{1,6}))?Z$/, + (_match: string, fraction: string | undefined) => `.${(fraction ?? "").padEnd(6, "0")}Z`, + ); +export const TokenAccountingReportId = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const boundedGroups = (schema: S) => + Schema.Array(schema).check(Schema.isMaxLength(TOKEN_ACCOUNTING_MAX_GROUPS)); + +export const TokenAccountingMetric = closed( + Schema.Struct({ + known_sum: Schema.NullOr(Count), + total: Schema.NullOr(Count), + known_requests: Count, + missing_requests: Count, + }), +).check( + Schema.makeFilter( + (metric) => + (metric.known_requests === 0 + ? metric.known_sum === null && metric.total === null + : metric.known_sum !== null) && + (metric.total === null || + (metric.missing_requests === 0 && metric.total === metric.known_sum)), + ), +); +export type TokenAccountingMetric = typeof TokenAccountingMetric.Type; + +const tokenMetricFields = { + input_tokens: TokenAccountingMetric, + cached_input_tokens: TokenAccountingMetric, + cache_write_input_tokens: TokenAccountingMetric, + cache_write_5m_tokens: TokenAccountingMetric, + cache_write_1h_tokens: TokenAccountingMetric, + output_tokens: TokenAccountingMetric, + reasoning_output_tokens: TokenAccountingMetric, +}; +const statusCounts = closed( + Schema.Struct({ + primary: Count, + legacy_unresolved: Count, + conflict: Count, + aggregate_delta: Count, + }), +); +const receiptStatusCounts = closed( + Schema.Struct({ + selected_stat_unchanged: Schema.optionalKey(Count), + selected_rebuilt: Schema.optionalKey(Count), + selected_append: Schema.optionalKey(Count), + retained_missing: Schema.optionalKey(Count), + retained_not_selected: Schema.optionalKey(Count), + archive_snapshot: Schema.optionalKey(Count), + }), +); +const providerCoverage = closed( + Schema.Struct({ + selected_requests: Count, + accounting_status_counts: statusCounts, + token_missingness: closed( + Schema.Struct({ + input_tokens: Count, + cached_input_tokens: Count, + cache_write_input_tokens: Count, + cache_write_5m_tokens: Count, + cache_write_1h_tokens: Count, + output_tokens: Count, + reasoning_output_tokens: Count, + }), + ), + latest_scan: closed( + Schema.Struct({ + captured_at: Schema.NullOr(Timestamp), + selected_files: Schema.NullOr(Count), + refreshed_selected_files: Schema.NullOr(Count), + partial_selected_files: Schema.NullOr(Count), + root_scope_ids: Schema.Array(TokenAccountingReportId).check( + Schema.isMaxLength(TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION), + ), + mtime_cutoff: Schema.NullOr(Timestamp), + receipts_status_counts: Schema.NullOr(receiptStatusCounts), + validated_files: Schema.NullOr(Count), + }), + ), + cumulative_index: closed( + Schema.Struct({ + tracked_files: Schema.NullOr(Count), + retained_not_refreshed_files: Schema.NullOr(Count), + missing_tracked_files: Schema.NullOr(Count), + }), + ), + freshness: closed( + Schema.Struct({ + status: Schema.Literals([ + "scan_unavailable", + "archive_snapshot_only", + "validation_receipts_unavailable", + "no_selected_source_validated", + "requested_end_unspecified", + "requested_end_after_selected_validation", + "retained_sources_not_refreshed", + "selected_sources_validated_after_requested_end", + ]), + requested_end: Schema.NullOr(Timestamp), + last_scan_captured_at: Schema.NullOr(Timestamp), + selected_validation_min: Schema.NullOr(Timestamp), + selected_validation_max: Schema.NullOr(Timestamp), + unrefreshed_files: Schema.NullOr(Count), + }), + ), + historical_window_completeness: Schema.Literal("not_proven"), + history_scope: Schema.optionalKey(Schema.Literal("all_indexed_history")), + indexed_history_selection: Schema.optionalKey( + closed( + Schema.Struct({ + requested_thread_count: Count, + indexed_thread_count: Count, + status: Schema.Literals(["no_indexed_requests", "partially_indexed", "indexed"]), + }), + ), + ), + }), +).check( + Schema.makeFilter( + (coverage) => + (coverage.history_scope === undefined) === (coverage.indexed_history_selection === undefined), + ), +); +const window = Schema.Union([ + closed( + Schema.Struct({ start: Timestamp, end: Timestamp, end_exclusive: Schema.Literal(true) }), + ).check( + Schema.makeFilter((value) => comparableTimestamp(value.start) < comparableTimestamp(value.end)), + ), + closed( + Schema.Struct({ + scope: Schema.Literal("all_indexed_history"), + start: Schema.Null, + end: Schema.Null, + end_exclusive: Schema.Literal(false), + }), + ), +]); +const snapshot = closed( + Schema.Struct({ + index_snapshot_id: TokenAccountingReportId, + source_revision: Schema.optionalKey(TokenAccountingReportId), + mapping_revision: Schema.optionalKey(TokenAccountingReportId), + scan_id: Schema.optionalKey(TokenAccountingReportId), + captured_at: Schema.optionalKey(Timestamp), + schema_version: Schema.optionalKey(Schema.Literal("programmatic-token-info.index/v1")), + source_validation: Schema.optionalKey( + Schema.Literals(["provider_append_only_stat_and_anchor", "archive_snapshot_only"]), + ), + }), +); +const allocationBandFields = { + basis: Schema.Literals(["measured_component", "estimated_calibrated"]), + central: Count, + low: Count, + high: Count, +}; +const inputAllocationGroup = Schema.Union([ + closed( + Schema.Struct({ + group: Schema.Literals([ + "user_role_message", + "tool_schema_config", + "client_control", + "nontext_input", + ]), + subtype: Schema.Literal("unknown"), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("guidance"), + subtype: Schema.Literals([ + "base_instructions", + "developer", + "system", + "session_task", + "unknown", + ]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("tool_result"), + subtype: Schema.Literals([ + "execution", + "file_read_code", + "file_read_document", + "search", + "build_test", + "vcs", + "web", + "mcp", + "agent", + "patch", + "other", + "unknown", + ]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("assistant_history"), + subtype: Schema.Literals(["assistant_text", "tool_call_arguments", "unknown"]), + ...allocationBandFields, + }), + ), +]); +const outputAllocationGroup = Schema.Union([ + closed( + Schema.Struct({ + group: Schema.Literal("assistant_text"), + subtype: Schema.Literals(["final", "commentary", "unphased", "unknown"]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("tool_call_arguments"), + subtype: Schema.Literals(["file_writing", "agent_launch", "other", "unknown"]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("other_generated"), + subtype: Schema.Literal("unknown"), + ...allocationBandFields, + }), + ), +]); +const unknownByReason = closed( + Schema.Struct({ + missing_counter: Schema.optionalKey(TokenAccountingMetric), + source_unavailable: Schema.optionalKey(TokenAccountingMetric), + ambiguous_join: Schema.optionalKey(TokenAccountingMetric), + estimator_unavailable: Schema.optionalKey(TokenAccountingMetric), + cache_placement_unsupported: Schema.optionalKey(TokenAccountingMetric), + envelope_incomplete: Schema.optionalKey(TokenAccountingMetric), + incompatible_scenario: Schema.optionalKey(TokenAccountingMetric), + outside_estimator_domain: Schema.optionalKey(TokenAccountingMetric), + nontext_unqualified: Schema.optionalKey(TokenAccountingMetric), + framing_or_hidden_unknown: Schema.optionalKey(TokenAccountingMetric), + }), +); +const allocationFields = { + allocated: TokenAccountingMetric, + unknown: TokenAccountingMetric, + unknown_by_reason: unknownByReason, + estimated_coverage: Schema.NullOr( + Schema.Number.check(Schema.isFinite(), Schema.isBetween({ minimum: 0, maximum: 1 })), + ), +}; +const partition = closed( + Schema.Struct({ + requests: Count, + metrics: closed(Schema.Struct({ ...tokenMetricFields, ordinary_input: TokenAccountingMetric })), + }), +); +export const TOKEN_ACCOUNTING_CAVEATS = [ + "primary_requests_only_are_additive", + "missing_counters_leave_totals_unknown", + "visible_inventory_is_not_submitted_payload", + "user_role_does_not_prove_owner_speech", + "candidate_envelope_is_unproved_submission", + "cache_placement_unsupported", + "unknown_residuals_are_not_zero", + "reasoning_is_an_output_subset", + "mechanism_flags_are_nonadditive", + "counter_changes_are_diagnostics_only", + "bands_use_held_out_group_bias", + "historical_window_completeness_not_proven", + "append_only_source_validation_limit", + "partial_counter_masks_limit_aggregate_conservation", +] as const; + +/** This is a bounded projection; Python validates the original report identity and conservation. */ +export const TokenAccountingReport = closed( + Schema.Struct({ + schema: Schema.Literal(TOKEN_ACCOUNTING_PROJECTION_SCHEMA), + source_schema: Schema.Literal(TOKEN_ACCOUNTING_REPORT_SCHEMA), + report_id: TokenAccountingReportId, + selection_id: TokenAccountingReportId, + source_identity_algorithm: Schema.Literal(TOKEN_ACCOUNTING_IDENTITY_ALGORITHM), + source_identity_verified: Schema.Literal(true), + snapshot, + window, + provider_coverage: providerCoverage, + provider_ledger: closed( + Schema.Struct({ + requests: Count, + primary_requests: Count, + nonadditive_requests: Count, + accounting_status_counts: statusCounts, + metrics: closed( + Schema.Struct({ + ...tokenMetricFields, + ordinary_input_tokens: TokenAccountingMetric, + non_read_input_tokens: TokenAccountingMetric, + }), + ), + }), + ), + input: closed( + Schema.Struct({ + ordinary_input: TokenAccountingMetric, + groups: boundedGroups(inputAllocationGroup), + ...allocationFields, + }), + ), + output: closed( + Schema.Struct({ + output: TokenAccountingMetric, + measured_reasoning: TokenAccountingMetric, + groups: boundedGroups(outputAllocationGroup), + ...allocationFields, + }), + ), + partitions: closed( + Schema.Struct({ + provider: closed(Schema.Struct({ codex: partition, claude: partition })), + role: closed(Schema.Struct({ root: partition, child: partition, unknown: partition })), + }), + ), + mechanism_flags: closed( + Schema.Struct({ + nonadditive: Schema.Literal(true), + counts: closed( + Schema.Struct({ + new_content: Schema.optionalKey(Count), + repeated_history: Schema.optionalKey(Count), + changed_guidance_schema_prefix: Schema.optionalKey(Count), + compaction_restart: Schema.optionalKey(Count), + first_observation_or_missing_predecessor: Schema.optionalKey(Count), + cache_miss_unchanged_reconstruction: Schema.optionalKey(Count), + mixed_unknown: Schema.optionalKey(Count), + }), + ), + }), + ), + estimator_status_counts: closed( + Schema.Struct({ qualified: Count, unavailable: Count, failed: Count }), + ), + coverage: closed( + Schema.Struct({ + primary_requests: Count, + captured_requests: Count, + unavailable_requests: Count, + complete_response_requests: Count, + input_allocated_requests: Count, + output_allocated_requests: Count, + reasoning_measured_requests: Count, + reasoning_missing_requests: Count, + estimator_qualified_models: Count, + estimator_unavailable_models: Count, + diagnostics: closed( + Schema.Struct({ + requests_output_without_generated_components: Schema.optionalKey(Count), + }), + ), + }), + ), + caveats: Schema.Array(Schema.Literals(TOKEN_ACCOUNTING_CAVEATS)).check( + Schema.makeFilter( + (values) => + values.length === TOKEN_ACCOUNTING_CAVEATS.length && + values.every((value, index) => value === TOKEN_ACCOUNTING_CAVEATS[index]), + ), + ), + authority_effect: Schema.Literal("none"), + }), +).check( + Schema.makeFilter( + (report) => + report.estimator_status_counts.qualified === report.coverage.estimator_qualified_models && + report.estimator_status_counts.unavailable + report.estimator_status_counts.failed === + report.coverage.estimator_unavailable_models, + ), +); +export type TokenAccountingReport = typeof TokenAccountingReport.Type; + +export const TokenAccountingReadInput = closed(Schema.Struct({})); +export type TokenAccountingReadInput = typeof TokenAccountingReadInput.Type; + +const unavailableVariants = [ + closed( + Schema.Struct({ + status: Schema.Literal("unconfigured"), + reason: Schema.Literals([ + "reader_unconfigured", + "report_unconfigured", + "host_binding_unverified", + ]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("missing"), + reason: Schema.Literal("configured_report_missing"), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("invalid"), + reason: Schema.Literals([ + "report_invalid", + "report_identity_mismatch", + "configured_report_id_mismatch", + "projection_invalid", + ]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("unsupported"), + reason: Schema.Literals(["report_schema_unsupported", "identity_algorithm_unsupported"]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("oversized"), + reason: Schema.Literals([ + "input_too_large", + "projection_too_large", + "collection_limit_exceeded", + ]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("reader_failed"), + reason: Schema.Literals(["reader_timeout", "reader_failed"]), + }), + ), +] as const; +export const TokenAccountingUnavailable = Schema.Union(unavailableVariants); +export type TokenAccountingUnavailable = typeof TokenAccountingUnavailable.Type; + +const observationFields = { + configuredReportId: Schema.NullOr(TokenAccountingReportId), + readAt: Timestamp, +}; +export const TokenAccountingReadResult = Schema.Union([ + closed( + Schema.Struct({ + state: Schema.Literal("ready"), + report: TokenAccountingReport, + readAt: Timestamp, + }), + ), + ...unavailableVariants.map((variant) => + closed( + Schema.Struct({ + state: Schema.Literal("unavailable"), + ...variant.fields, + ...observationFields, + }), + ), + ), +]); +export type TokenAccountingReadResult = typeof TokenAccountingReadResult.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index f6282b7b2..bc31bc516 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -54,7 +54,7 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P ); export type UsageDay = typeof UsageDay.Type; -export const UsageResolution = Schema.Literals(["day", "hour"]); +export const UsageResolution = Schema.Literals(["day", "hour", "exactDay"]); export type UsageResolution = typeof UsageResolution.Type; /** @@ -211,11 +211,11 @@ export const UsageSummaryInput = Schema.Struct({ * any window that crosses a DST boundary. */ timeZone: TrimmedNonEmptyString, - /** Defaults to daily for older clients. */ + /** Defaults to day; `exactDay` filters by paired instants and keeps day buckets. */ resolution: Schema.optional(UsageResolution), - /** Inclusive UTC instant for an hourly rolling window. */ + /** Inclusive UTC instant for hourly or exact-day windows. */ sinceTime: Schema.optional(TrimmedNonEmptyString), - /** Exclusive UTC instant for an hourly rolling window. */ + /** Exclusive UTC instant for hourly or exact-day windows. */ untilTime: Schema.optional(TrimmedNonEmptyString), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 8208e5b7e..356451c99 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -785,4 +785,207 @@ describe("mergeUsage", () => { expect(merged.daily).toHaveLength(1); expect(merged.daily[0]?.costUsd).toBe(10); }); + + it("reconciles provider and model detail vectors after copied and overlapping sources", () => { + const claudeSource = { + provider: "claude" as const, + hostId: "mac", + homePath: "/claude", + distinctSessions: 2, + }; + const accepted = [ + bucket({ + sourcePath: "/claude", + model: "primary", + costUsd: 6, + costSource: "providerReported", + records: 2, + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 50, + cacheCreationTokens: 10, + outputTokens: 30, + reasoningTokens: 5, + }, + }), + bucket({ + sourcePath: "/claude", + model: "primary", + day: "2026-08-06" as UsageDay, + costUsd: 2, + records: 5, + unpricedRecords: 2, + totals: { + uncachedInputTokens: 200, + cachedInputTokens: 100, + cacheCreationTokens: 20, + outputTokens: 40, + reasoningTokens: 10, + }, + }), + ]; + const complete = summary(accepted, [claudeSource]); + const partial = summary( + [ + ...accepted.map((entry) => ({ + ...entry, + costUsd: 900, + totals: { ...entry.totals, outputTokens: 9000 }, + })), + bucket({ + sourcePath: "/claude", + day: "2026-08-08" as UsageDay, + model: "secondary", + costUsd: 0, + costSource: "unpriced", + records: 3, + unpricedRecords: 3, + totals: { + uncachedInputTokens: 30, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 10, + reasoningTokens: 4, + }, + }), + ], + [claudeSource], + ); + const merged = mergeUsage( + [ + environment("env-a", complete), + environment("env-copy", complete), + environment("env-partial", { + ...partial, + readAt: "2026-08-09T00:00:00.000Z", + sources: partial.sources.map((source) => ({ + ...source, + status: "partial", + distinctSessions: 3, + })), + }), + environment( + "env-codex", + summary( + [ + bucket({ + provider: "codex", + model: "codex-model", + costUsd: 4, + costSource: "providerReported", + records: 1, + totals: { + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 5, + reasoningTokens: 2, + }, + }), + ], + [{ provider: "codex", hostId: "linux", homePath: "/codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers.find((entry) => entry.provider === "claude")).toMatchObject({ + costUsd: 8, + totalTokens: 590, + records: 10, + sessions: 3, + totals: { + uncachedInputTokens: 330, + cachedInputTokens: 150, + cacheCreationTokens: 30, + outputTokens: 80, + reasoningTokens: 19, + }, + providerReportedRecords: 2, + modelPricedRecords: 3, + unpricedRecords: 5, + }); + expect(merged.models.find((entry) => entry.model === "primary")).toMatchObject({ + totalTokens: 550, + totals: { + uncachedInputTokens: 300, + cachedInputTokens: 150, + cacheCreationTokens: 30, + outputTokens: 70, + reasoningTokens: 15, + }, + providerReportedRecords: 2, + modelPricedRecords: 3, + unpricedRecords: 2, + }); + expect(merged.models.find((entry) => entry.model === "secondary")).toMatchObject({ + totalTokens: 40, + providerReportedRecords: 0, + modelPricedRecords: 0, + unpricedRecords: 3, + }); + expect(merged.totalTokens).toBe(625); + expect(merged.records).toBe(11); + expect(merged.sessions).toBe(4); + expect(merged.duplicateSources).toHaveLength(2); + for (const field of [ + "uncachedInputTokens", + "cachedInputTokens", + "cacheCreationTokens", + "outputTokens", + "reasoningTokens", + ] as const) { + expect(merged.providers.reduce((sum, entry) => sum + entry.totals[field], 0)).toBe( + merged[field], + ); + expect(merged.models.reduce((sum, entry) => sum + entry.totals[field], 0)).toBe( + merged[field], + ); + } + expect(merged.costQuality.providerReportedShare).toBe(3 / 11); + expect(merged.costQuality.modelPricedShare).toBe(3 / 11); + expect(merged.costQuality.unpricedShare).toBe(5 / 11); + }); + + it("retains zero-priced coverage and does not count reasoning twice", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + records: 1, + costUsd: 0, + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 20, + reasoningTokens: 20, + }, + }), + ], + [{ provider: "claude", hostId: "mac", homePath: "/claude" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers[0]).toMatchObject({ + totalTokens: 20, + records: 1, + providerReportedRecords: 0, + modelPricedRecords: 1, + unpricedRecords: 0, + }); + expect(merged.models[0]).toMatchObject({ + totalTokens: 20, + modelPricedRecords: 1, + unpricedRecords: 0, + }); + expect(isModelCostUnknown(merged.models[0]!)).toBe(false); + }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 07718c099..7d6a9f7c3 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -28,6 +28,10 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + readonly totals: UsageTokenTotals; + readonly providerReportedRecords: number; + readonly modelPricedRecords: number; + readonly unpricedRecords: number; readonly sessions: number; readonly costShare: number; readonly tokenShare: number; @@ -40,6 +44,9 @@ export interface ModelTotals { readonly totalTokens: number; readonly tokens: UsageTokenTotals; readonly records: number; + readonly totals: UsageTokenTotals; + readonly providerReportedRecords: number; + readonly modelPricedRecords: number; /** * Records whose tokens are counted here but which contributed nothing to * `costUsd`. When it equals `records` the cost is unknown, not zero. @@ -314,6 +321,35 @@ function bucketTokens(bucket: UsageBucket): number { ); } +function emptyDetailTotals() { + return { + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + } satisfies UsageTokenTotals, + providerReportedRecords: 0, + modelPricedRecords: 0, + unpricedRecords: 0, + }; +} + +function addDetailTotals(detail: ReturnType, bucket: UsageBucket) { + detail.totals.uncachedInputTokens += bucket.totals.uncachedInputTokens; + detail.totals.cachedInputTokens += bucket.totals.cachedInputTokens; + detail.totals.cacheCreationTokens += bucket.totals.cacheCreationTokens; + detail.totals.outputTokens += bucket.totals.outputTokens; + detail.totals.reasoningTokens += bucket.totals.reasoningTokens; + const providerReportedRecords = bucket.costSource === "providerReported" ? bucket.records : 0; + detail.providerReportedRecords += providerReportedRecords; + detail.unpricedRecords += bucket.unpricedRecords; + // Match the existing cost-quality classification; the wire bucket carries + // unpriced counts and one cost source, not per-response pricing provenance. + detail.modelPricedRecords += bucket.records - providerReportedRecords - bucket.unpricedRecords; +} + export function isCompatibleUsageContractVersion(version: number, expected: number): boolean { return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected; } @@ -402,17 +438,21 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number; sessions: number } + ReturnType & { + costUsd: number; + totalTokens: number; + records: number; + sessions: number; + } >(); const modelAccumulator = new Map< string, - { + ReturnType & { provider: UsageProviderKind; costUsd: number; totalTokens: number; tokens: UsageTokenTotals; records: number; - unpricedRecords: number; unpricedTokens: number; } >(); @@ -449,6 +489,7 @@ export function mergeUsage( sessions += providerSessions; if (providerSessions === 0) continue; const provider = providerAccumulator.get(providerKind) ?? { + ...emptyDetailTotals(), costUsd: 0, totalTokens: 0, records: 0, @@ -482,6 +523,7 @@ export function mergeUsage( speedCost.premium += bucket.speedPremiumUsd ?? 0; const provider = providerAccumulator.get(bucket.provider) ?? { + ...emptyDetailTotals(), costUsd: 0, totalTokens: 0, records: 0, @@ -490,10 +532,12 @@ export function mergeUsage( provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; provider.records += bucket.records; + addDetailTotals(provider, bucket); providerAccumulator.set(bucket.provider, provider); const modelKey = `${bucket.provider} ${bucket.model}`; const model = modelAccumulator.get(modelKey) ?? { + ...emptyDetailTotals(), provider: bucket.provider, costUsd: 0, totalTokens: 0, @@ -505,7 +549,6 @@ export function mergeUsage( reasoningTokens: 0, }, records: 0, - unpricedRecords: 0, unpricedTokens: 0, }; model.costUsd += bucket.costUsd; @@ -518,7 +561,7 @@ export function mergeUsage( reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens, }; model.records += bucket.records; - model.unpricedRecords += bucket.unpricedRecords; + addDetailTotals(model, bucket); if (bucket.records > 0) { model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records; } @@ -567,6 +610,10 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + totals: totals.totals, + providerReportedRecords: totals.providerReportedRecords, + modelPricedRecords: totals.modelPricedRecords, + unpricedRecords: totals.unpricedRecords, sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, @@ -581,6 +628,9 @@ export function mergeUsage( totalTokens: totals.totalTokens, tokens: totals.tokens, records: totals.records, + totals: totals.totals, + providerReportedRecords: totals.providerReportedRecords, + modelPricedRecords: totals.modelPricedRecords, unpricedRecords: totals.unpricedRecords, unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, From e8d85902efa7b7ee8963c8ba0cf1ad2ae070d0f5 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 04:33:31 +0200 Subject: [PATCH 17/59] Checkpoint desktop runtime identity source for dependent ports --- .../src/app/DesktopAppIdentity.test.ts | 82 ++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 40 ++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/window.test.ts | 67 ++++++++- apps/desktop/src/ipc/methods/window.ts | 12 ++ apps/desktop/src/preload.ts | 2 + .../src/mcp/PreviewAutomationBroker.test.ts | 131 ++++++++++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 31 ++++- .../preview/PreviewAutomationHosts.test.tsx | 74 +++++++++- .../preview/PreviewAutomationHosts.tsx | 31 ++++- packages/contracts/src/ipc.ts | 2 + packages/contracts/src/preview.test.ts | 40 ++++++ packages/contracts/src/previewAutomation.ts | 29 +++- scripts/build-desktop-artifact.test.ts | 37 +++++ scripts/build-desktop-artifact.ts | 6 +- 16 files changed, 570 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 8f5eb4e84..b486f21c6 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -113,6 +113,7 @@ const withIdentity = ( readonly legacyPathExists?: boolean; readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; + readonly readPackageJson?: () => Effect.Effect; readonly pngIconPath?: Option.Option; } = {}, ) => { @@ -134,8 +135,8 @@ const withIdentity = ( : Effect.succeed( input.legacyPathExists === true && /T3 Code \((Alpha|Dev)\)/.test(path), ), - readFileString: () => - Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), + readFileString: input.readPackageJson ?? (() => + Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}')), }), ), Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())), @@ -147,6 +148,40 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { + it.effect("keeps a process runtime identity with only the full embedded commit", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const first = yield* identity.previewAutomationRuntimeIdentity; + const second = yield* identity.previewAutomationRuntimeIdentity; + assert.deepEqual(second, first); + assert.match(first.runtimeInstanceId, /^[0-9a-f-]{36}$/i); + assert.deepEqual(first, { + schemaVersion: 1, + runtimeKind: "electron", + runtimeInstanceId: first.runtimeInstanceId, + appVersion: "1.2.3", + buildCommit: "abcdef1234567890abcdef1234567890abcdef12", + }); + }), + { + packageJson: '{"t3codeCommitHash":"ABCDEF1234567890ABCDEF1234567890ABCDEF12"}', + environment: { env: { T3CODE_COMMIT_HASH: "0123456789abcdef" } }, + }, + ), + ); + + it.effect("reports no build commit when embedded metadata is abbreviated", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const runtime = yield* identity.previewAutomationRuntimeIdentity; + assert.equal(runtime.buildCommit, null); + }), + { packageJson: '{"t3codeCommitHash":"abcdef123456"}' }, + ), + ); + it.effect("uses an explicit client profile independently of the server home", () => withIdentity( Effect.gen(function* () { @@ -299,3 +334,46 @@ describe("DesktopAppIdentity", () => { ); }); }); + +it.effect.each(['{}', '{"t3codeCommitHash":42}', '{broken', '{"t3codeCommitHash":"z"}'])( + "reports no runtime commit for invalid or missing metadata: %s", + (packageJson) => withIdentity(Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + assert.equal((yield* identity.previewAutomationRuntimeIdentity).buildCommit, null); + }), { packageJson }), +); + +it.effect("caches the embedded descriptor independently from the About override", () => { + let reads = 0; + const calls: ElectronAppCalls = { setAboutPanelOptions: [], setDockIcon: [], setName: [] }; + return withIdentity(Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const first = yield* identity.previewAutomationRuntimeIdentity; + const second = yield* identity.previewAutomationRuntimeIdentity; + assert.strictEqual(second, first); + assert.equal(reads, 1); + yield* identity.configure; + assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); + assert.equal(first.buildCommit, "a".repeat(40)); + assert.equal(reads, 1); + }), { + calls, + environment: { env: { T3CODE_COMMIT_HASH: "0123456789abcdef" } }, + readPackageJson: () => Effect.sync(() => { + reads += 1; + return JSON.stringify({ t3codeCommitHash: (reads === 1 ? "A" : "B").repeat(40) }); + }), + }); +}); + +it.effect("reports no runtime commit when package metadata is unreadable", () => + withIdentity(Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + assert.equal((yield* identity.previewAutomationRuntimeIdentity).buildCommit, null); + }), { + readPackageJson: () => Effect.fail(PlatformError.systemError({ + _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", + pathOrDescriptor: "/synthetic/package.json", description: "synthetic denied read", + })), + }), +); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 69fce083d..1513124b5 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -1,3 +1,5 @@ +import * as NodeCrypto from "node:crypto"; +import type { PreviewAutomationRuntimeIdentity } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -13,7 +15,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; +const FULL_COMMIT_HASH_PATTERN = /^[0-9a-f]{40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; +const runtimeInstanceId = NodeCrypto.randomUUID(); const AppPackageMetadata = Schema.Struct({ t3codeCommitHash: Schema.optional(Schema.String), @@ -27,6 +31,7 @@ export class DesktopAppIdentity extends Context.Service< string, DesktopUserData.DesktopUserDataInitializationError >; + readonly previewAutomationRuntimeIdentity: Effect.Effect; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -47,6 +52,40 @@ export const make = Effect.gen(function* () { const userDataContext = yield* Effect.context(); const commitHashCache = yield* Ref.make>>(Option.none()); + const runtimeIdentityCache = yield* Ref.make>( + Option.none(), + ); + + const previewAutomationRuntimeIdentity = Effect.gen(function* () { + const cached = yield* Ref.get(runtimeIdentityCache); + if (Option.isSome(cached)) return cached.value; + + const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); + const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); + const buildCommit = yield* Option.match(raw, { + onNone: () => Effect.succeed(null), + onSome: (value) => + decodeAppPackageMetadata(value).pipe( + Effect.map((parsed) => { + const commit = parsed.t3codeCommitHash?.trim(); + return commit !== undefined && FULL_COMMIT_HASH_PATTERN.test(commit) + ? commit.toLowerCase() + : null; + }), + Effect.orElseSucceed(() => null), + ), + }); + const identity = { + schemaVersion: 1, + runtimeKind: "electron", + runtimeInstanceId, + appVersion: environment.appVersion, + buildCommit, + } as const; + yield* Ref.set(runtimeIdentityCache, Option.some(identity)); + return identity; + }); + const resolveEmbeddedCommitHash = Effect.gen(function* () { const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); @@ -116,6 +155,7 @@ export const make = Effect.gen(function* () { return DesktopAppIdentity.of({ resolveUserDataPath: userDataPath, + previewAutomationRuntimeIdentity, configure, }); }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 4b43cd0ee..ee3834f85 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -39,6 +39,7 @@ import { } from "./methods/updates.ts"; import { getAppBranding, + getPreviewAutomationRuntimeIdentity, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getSystemLocale, @@ -81,6 +82,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.complete); yield* ipc.handleSync(getAppBranding); + yield* ipc.handle(getPreviewAutomationRuntimeIdentity); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 151cd633d..34ca38881 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -24,6 +24,8 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_PREVIEW_AUTOMATION_RUNTIME_IDENTITY_CHANNEL = + "desktop:get-preview-automation-runtime-identity"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 1dd1ca8d0..259c89f7d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -10,15 +10,27 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; -const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ +const { focusedWebContents, ownerWindow, exposeInMainWorld, invoke } = vi.hoisted(() => ({ focusedWebContents: vi.fn(), ownerWindow: vi.fn(), + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), })); vi.mock("electron", () => ({ webContents: { getFocusedWebContents: focusedWebContents }, BrowserWindow: { fromWebContents: ownerWindow }, + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke }, + webFrame: {}, + webUtils: {}, })); +vi.mock("@clerk/electron/preload", () => ({ exposeClerkBridge: vi.fn() })); + +import type { DesktopBridge } from "@t3tools/contracts"; +import * as DesktopAppIdentity from "../../app/DesktopAppIdentity.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; @@ -26,6 +38,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, + getPreviewAutomationRuntimeIdentity, getWindowFullscreenState, pasteAsText, pickProjectFavicon, @@ -298,3 +311,55 @@ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( assert.notInclude(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +describe("getPreviewAutomationRuntimeIdentity", () => { + const descriptor = { + schemaVersion: 1, runtimeKind: "electron", runtimeInstanceId: "synthetic-runtime", + appVersion: "1.2.3", buildCommit: "a".repeat(40), + } as const; + + it.effect("returns the service descriptor through the registered async IPC method", () => + Effect.gen(function* () { + let listener: DesktopIpc.DesktopIpcHandleListener | undefined; + const ipc = DesktopIpc.make({ + removeHandler: vi.fn(), removeAllListeners: vi.fn(), on: vi.fn(), + handle: (channel, registered) => { + assert.equal(channel, IpcChannels.GET_PREVIEW_AUTOMATION_RUNTIME_IDENTITY_CHANNEL); + listener = registered; + }, + }); + yield* ipc.handle(getPreviewAutomationRuntimeIdentity); + assert.deepEqual(yield* Effect.promise(async () => listener!({ sender: { id: 1 } }, undefined)), descriptor); + const invalidPayload = yield* Effect.exit(getPreviewAutomationRuntimeIdentity.handler("unexpected")); + assert.equal(invalidPayload._tag, "Failure"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mock(DesktopAppIdentity.DesktopAppIdentity)({ + previewAutomationRuntimeIdentity: Effect.succeed(descriptor), + })), + ), + ); + + it.effect("rejects a descriptor that violates the IPC result schema", () => + getPreviewAutomationRuntimeIdentity.handler(undefined).pipe( + Effect.provide(Layer.mock(DesktopAppIdentity.DesktopAppIdentity)({ + previewAutomationRuntimeIdentity: Effect.succeed({ ...descriptor, schemaVersion: 2 } as unknown as typeof descriptor), + })), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => assert.equal(exit._tag, "Failure"))), + ), + ); + + it("exposes an async preload getter using the identity channel", async () => { + vi.stubGlobal("window", { addEventListener: vi.fn() }); + try { + await import("../../preload.ts"); + const bridge = exposeInMainWorld.mock.calls.find(([name]) => name === "desktopBridge")?.[1] as DesktopBridge; + invoke.mockResolvedValueOnce(descriptor); + assert.deepEqual(await bridge.getPreviewAutomationRuntimeIdentity!(), descriptor); + assert.deepEqual(invoke.mock.calls.at(-1), [IpcChannels.GET_PREVIEW_AUTOMATION_RUNTIME_IDENTITY_CHANNEL]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07..960497c3b 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -7,6 +7,7 @@ import { EditorId, PickedThemeFileSchema, PickFolderOptionsSchema, + PreviewAutomationRuntimeIdentity, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, SystemSettingsPaneSchema, @@ -26,6 +27,7 @@ import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopAppIdentity from "../../app/DesktopAppIdentity.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -71,6 +73,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getPreviewAutomationRuntimeIdentity = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_PREVIEW_AUTOMATION_RUNTIME_IDENTITY_CHANNEL, + payload: Schema.Void, + result: PreviewAutomationRuntimeIdentity, + handler: Effect.fn("desktop.ipc.window.getPreviewAutomationRuntimeIdentity")(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + return yield* identity.previewAutomationRuntimeIdentity; + }), +}); + export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, result: Schema.String, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e84a5821e..134afb88e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -70,6 +70,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPreviewAutomationRuntimeIdentity: () => + ipcRenderer.invoke(IpcChannels.GET_PREVIEW_AUTOMATION_RUNTIME_IDENTITY_CHANNEL), getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index adf55cad5..90a2792bb 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -94,6 +94,71 @@ it.effect("atomically registers a connected host and correlates its response", ( ), ); +it.effect("adds a connection-bound selected client receipt to status only", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { available: true, selectedClient: { clientId: "forged" } }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const result = yield* broker.invoke<{ + available: boolean; + selectedClient: { + clientId: string; + connectionId: string; + requestId: string; + runtimeIdentity: null; + }; + }>({ scope, operation: "status", input: {} }); + expect(result.selectedClient).toMatchObject({ + clientId: "client-1", + requestId: "preview-0", + runtimeIdentity: null, + }); + expect(result.selectedClient.connectionId).toBeTruthy(); + }), + ), +); + +it.effect("binds the runtime descriptor to the selected connection", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const runtimeIdentity = { + schemaVersion: 1, + runtimeKind: "electron", + runtimeInstanceId: "runtime-1", + appVersion: "0.1.0", + buildCommit: "a".repeat(40), + } as const; + const requests = requestsFrom(yield* broker.connect(makeHost({ runtimeIdentity }))); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { available: true, selectedClient: { clientId: "forged" } }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const result = yield* broker.invoke<{ + selectedClient: { runtimeIdentity: typeof runtimeIdentity; completedAt: string }; + }>({ scope, operation: "status", input: {} }); + expect(result.selectedClient.runtimeIdentity).toEqual(runtimeIdentity); + expect(Number.isNaN(Date.parse(result.selectedClient.completedAt))).toBe(false); + }), + ), +); + it.effect("targets multiple tabs explicitly while retaining a default tab", () => Effect.scoped( Effect.gen(function* () { @@ -1491,3 +1556,69 @@ it.effect("keeps a host that responds with an operation timeout", () => }), ), ); + +it.effect("authors status receipts only for successful object results", () => + Effect.scoped(Effect.gen(function* () { + const broker = yield* makeBroker; + let responseResult: unknown = null; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => broker.respond({ + clientId: "client-1", connectionId: request.connectionId, requestId: request.requestId, + ok: true, result: responseResult, + })).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + for (const result of [null, [], "legacy-status", 42]) { + responseResult = result; + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toEqual(result); + } + for (const operation of ["open", "navigate"] as const) { + responseResult = { available: true, selectedClient: { clientId: "client-authored-action" } }; + expect(yield* broker.invoke({ scope, operation, input: {} })).toEqual(responseResult); + } + })), +); + +it.effect("never transfers a pending runtime receipt to a replacement connection", () => + Effect.scoped(Effect.gen(function* () { + const broker = yield* makeBroker; + const oldIdentity = { + schemaVersion: 1, runtimeKind: "electron", runtimeInstanceId: "old-runtime", + appVersion: "1.0.0", buildCommit: "a".repeat(40), + } as const; + const newIdentity = { ...oldIdentity, runtimeInstanceId: "new-runtime", buildCommit: "b".repeat(40) }; + const oldRequestReady = yield* Deferred.make(); + yield* Stream.runForEach( + requestsFrom(yield* broker.connect(makeHost({ runtimeIdentity: oldIdentity }))), + (request) => Deferred.succeed(oldRequestReady, request), + ).pipe(Effect.forkScoped); + const pending = yield* broker.invoke({ scope, operation: "status", input: {} }).pipe(Effect.flip, Effect.forkScoped); + const oldRequest = yield* Deferred.await(oldRequestReady); + const newRequestReady = yield* Deferred.make(); + yield* Stream.runForEach( + requestsFrom(yield* broker.connect(makeHost({ runtimeIdentity: newIdentity }))), + (request) => Deferred.succeed(newRequestReady, request), + ).pipe(Effect.forkScoped); + expect(yield* Fiber.join(pending)).toBeInstanceOf(PreviewAutomationClientDisconnectedError); + const current = yield* broker.invoke<{ + selectedClient: { clientId: string; connectionId: string; requestId: string; runtimeIdentity: typeof newIdentity }; + }>({ scope, operation: "status", input: {} }).pipe(Effect.forkScoped); + const request = yield* Deferred.await(newRequestReady); + expect(request.connectionId).not.toBe(oldRequest.connectionId); + for (const mismatch of [ + { clientId: "foreign", connectionId: request.connectionId, requestId: request.requestId }, + { clientId: "client-1", connectionId: oldRequest.connectionId, requestId: request.requestId }, + { clientId: "client-1", connectionId: request.connectionId, requestId: oldRequest.requestId }, + ]) { + yield* broker.respond({ ...mismatch, ok: true, result: { selectedClient: { runtimeIdentity: oldIdentity } } }); + } + yield* broker.respond({ + clientId: "client-1", connectionId: request.connectionId, requestId: request.requestId, + ok: true, result: { selectedClient: { clientId: "forged", runtimeIdentity: oldIdentity } }, + }); + const result = yield* Fiber.join(current); + expect(result.selectedClient).toMatchObject({ + clientId: "client-1", connectionId: request.connectionId, requestId: request.requestId, + runtimeIdentity: newIdentity, + }); + })), +); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 65e3064f4..db7981273 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -28,6 +28,7 @@ import { import * as Context from "effect/Context"; import type * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -71,6 +72,7 @@ interface ClientConnection { readonly clientId: string; readonly connectionId: string; readonly environmentId: PreviewAutomationHost["environmentId"]; + readonly runtimeIdentity: PreviewAutomationHost["runtimeIdentity"]; readonly supportedOperations: ReadonlySet; readonly focused: boolean; readonly liveTabs: NonNullable; @@ -80,6 +82,7 @@ interface ClientConnection { interface PendingRequest { readonly queue: ClientConnection["queue"]; + readonly runtimeIdentity: ClientConnection["runtimeIdentity"]; readonly deferred: Deferred.Deferred; readonly context: PreviewAutomationRequestErrorContext; } @@ -375,6 +378,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { clientId, connectionId, environmentId: host.environmentId, + runtimeIdentity: host.runtimeIdentity, supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), focused: false, liveTabs: [], @@ -459,7 +463,25 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); if (!pending) return; if (response.ok) { - yield* Deferred.succeed(pending.deferred, response.result); + const result = response.result; + yield* Deferred.succeed( + pending.deferred, + pending.context.operation === "status" && + typeof result === "object" && + result !== null && + !Array.isArray(result) + ? { + ...result, + selectedClient: { + clientId: pending.context.clientId, + connectionId: pending.context.connectionId, + requestId: pending.context.requestId, + completedAt: DateTime.formatIso(yield* DateTime.now), + runtimeIdentity: pending.runtimeIdentity ?? null, + }, + } + : result, + ); } else { yield* Deferred.fail( pending.deferred, @@ -556,7 +578,12 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ...selectorDiagnostics, }; const pending = new Map(current.pending); - pending.set(requestId, { queue: connection.queue, deferred, context }); + pending.set(requestId, { + queue: connection.queue, + runtimeIdentity: connection.runtimeIdentity, + deferred, + context, + }); return [ { connection, requestId, requestContext: context, requestSequence }, { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx index 542b84907..8fcf45d3e 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -4,6 +4,8 @@ import { ThreadId, type ClientSettings, type PreviewAutomationResponse, + type PreviewAutomationRuntimeIdentity, + type PreviewAutomationHost, type PreviewAutomationStreamEvent, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -29,6 +31,8 @@ import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; const mocks = vi.hoisted(() => ({ + environments: [] as Array<{ environmentId: EnvironmentId }>, + automationRequests: vi.fn<(target: { environmentId: EnvironmentId; input: PreviewAutomationHost }) => typeof requestsAtom>(), getClientSettings: vi.fn<() => Promise>(), setClientSettings: vi.fn(), open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => @@ -48,11 +52,11 @@ vi.mock("~/localApi", () => ({ })); vi.mock("~/env", () => ({ isElectron: true })); vi.mock("~/state/environments", () => ({ - useEnvironments: () => ({ environments: [{ environmentId }] }), + useEnvironments: () => ({ environments: mocks.environments }), })); vi.mock("~/state/preview", () => ({ previewEnvironment: { - automationRequests: () => requestsAtom, + automationRequests: mocks.automationRequests, list: () => listAtom, open: mocks.open, resize: mocks.resize, @@ -117,6 +121,8 @@ let renderer: ReactTestRenderer | null = null; beforeEach(async () => { vi.clearAllMocks(); + mocks.environments = [{ environmentId }]; + mocks.automationRequests.mockReset().mockReturnValue(requestsAtom); mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); mocks.respond.mockReset(); mocks.focus.mockReset().mockResolvedValue(AsyncResult.success(undefined)); @@ -361,3 +367,67 @@ describe("PreviewAutomationHosts ownership", () => { }); }); }); + +const runtimeIdentity: PreviewAutomationRuntimeIdentity = { + schemaVersion: 1, + runtimeKind: "electron", + runtimeInstanceId: "synthetic-desktop-runtime", + appVersion: "1.2.3", + buildCommit: "a".repeat(40), +}; + +async function remountWithRuntimeGetter( + getter?: () => Promise, +) { + await act(() => renderer?.unmount()); + renderer = null; + mocks.automationRequests.mockClear(); + Object.assign(window, { desktopBridge: getter ? { getPreviewAutomationRuntimeIdentity: getter } : {} }); + await act(() => { + renderer = create(); + }); +} + +describe("PreviewAutomationHosts runtime identity", () => { + it("waits for the descriptor before registering it for every environment", async () => { + const pending = deferred(); + const getter = vi.fn(() => pending.promise); + const secondEnvironmentId = EnvironmentId.make("second-environment"); + mocks.environments = [{ environmentId }, { environmentId: secondEnvironmentId }]; + await remountWithRuntimeGetter(getter); + expect(getter).toHaveBeenCalledOnce(); + expect(mocks.automationRequests).not.toHaveBeenCalled(); + await act(async () => { pending.resolve(runtimeIdentity); await pending.promise; }); + for (const id of [environmentId, secondEnvironmentId]) { + expect(mocks.automationRequests).toHaveBeenCalledWith({ + environmentId: id, + input: expect.objectContaining({ environmentId: id, runtimeIdentity }), + }); + } + }); + + it("registers legacy hosts when the optional getter is absent", async () => { + await remountWithRuntimeGetter(); + expect(mocks.automationRequests).toHaveBeenCalled(); + for (const [target] of mocks.automationRequests.mock.calls) { + expect(target.input).not.toHaveProperty("runtimeIdentity"); + } + }); + + it("registers legacy hosts after the descriptor getter rejects", async () => { + await remountWithRuntimeGetter(() => Promise.reject(new Error("synthetic unavailable IPC"))); + expect(mocks.automationRequests).toHaveBeenCalled(); + for (const [target] of mocks.automationRequests.mock.calls) { + expect(target.input).not.toHaveProperty("runtimeIdentity"); + } + }); + + it("ignores a descriptor resolved after unmount", async () => { + const pending = deferred(); + await remountWithRuntimeGetter(() => pending.promise); + await act(() => renderer?.unmount()); + renderer = null; + await act(async () => { pending.resolve(runtimeIdentity); await pending.promise; }); + expect(mocks.automationRequests).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 4a3f9b7c8..cfdc1006f 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -15,6 +15,7 @@ import { type PreviewAutomationSetColorSchemeResult, type PreviewAutomationHost as PreviewAutomationHostState, type PreviewAutomationRequest, + type PreviewAutomationRuntimeIdentity, type PreviewAutomationStatus, type PreviewRenderedViewportSize, type PreviewViewportSetting, @@ -276,7 +277,26 @@ const raisePreviewAutomationHostError = ( export function PreviewAutomationHosts() { const { environments } = useEnvironments(); + const [runtimeIdentity, setRuntimeIdentity] = useState< + PreviewAutomationRuntimeIdentity | null | undefined + >(() => (window.desktopBridge?.getPreviewAutomationRuntimeIdentity ? undefined : null)); + useEffect(() => { + let active = true; + const getter = window.desktopBridge?.getPreviewAutomationRuntimeIdentity; + if (!getter) return; + void getter() + .then((identity) => { + if (active) setRuntimeIdentity(identity); + }) + .catch(() => { + if (active) setRuntimeIdentity(null); + }); + return () => { + active = false; + }; + }, []); if (!isElectron || !previewBridge?.automation) return null; + if (runtimeIdentity === undefined) return null; return ( <> {/* @@ -288,14 +308,18 @@ export function PreviewAutomationHosts() { ))} ); } -function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { - const { environmentId } = props; +function PreviewAutomationHost(props: { + readonly environmentId: EnvironmentId; + readonly runtimeIdentity: PreviewAutomationRuntimeIdentity | null; +}) { + const { environmentId, runtimeIdentity } = props; const previewSessions = useActivePreviewSessions(); const visibleRuntimeTabIds = useBrowserSurfaceStore( useShallow((state) => @@ -327,8 +351,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) clientId: automationClientId, environmentId, supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], + ...(runtimeIdentity === null ? {} : { runtimeIdentity }), }), - [automationClientId, environmentId], + [automationClientId, environmentId, runtimeIdentity], ); const automationRequestsAtom = previewEnvironment.automationRequests({ environmentId, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index db2cc2a36..031e1d790 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -4,6 +4,7 @@ import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationPressInput, + PreviewAutomationRuntimeIdentity, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -1123,6 +1124,7 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + getPreviewAutomationRuntimeIdentity?: () => Promise; /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ getPathForFile?: (file: File) => string; /** The desktop client's OS platform, read from Electron's preload process. */ diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745..e5647e29a 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -17,6 +17,7 @@ import { PreviewAutomationOpenInput, PreviewAutomationResizeInput, PreviewAutomationResizeResult, + PreviewAutomationRuntimeIdentity, PreviewAutomationStatus, } from "./previewAutomation.ts"; @@ -32,6 +33,7 @@ const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResul const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); +const decodeRuntimeIdentity = Schema.decodeUnknownSync(PreviewAutomationRuntimeIdentity); describe("PreviewAutomationOpenInput", () => { it("accepts the inline preview visibility flag", () => { @@ -172,6 +174,26 @@ describe("PreviewAutomationHost", () => { }).supportedOperations, ).toEqual(["status", "resize"]); }); + it("accepts a versioned runtime descriptor and rejects incompatible versions", () => { + const runtimeIdentity = { + schemaVersion: 1, + runtimeKind: "electron", + runtimeInstanceId: "runtime-1", + appVersion: "0.1.0", + buildCommit: "a".repeat(40), + }; + expect(decodeRuntimeIdentity(runtimeIdentity)).toEqual(runtimeIdentity); + expect( + decodeAutomationHost({ clientId: "current", environmentId: "environment-1", runtimeIdentity }) + .runtimeIdentity, + ).toEqual(runtimeIdentity); + expect(() => decodeRuntimeIdentity({ ...runtimeIdentity, schemaVersion: 2 })).toThrow(); + expect(() => decodeRuntimeIdentity({ ...runtimeIdentity, runtimeKind: "browser" })).toThrow(); + expect(() => + decodeRuntimeIdentity({ ...runtimeIdentity, buildCommit: "a".repeat(39) }), + ).toThrow(); + expect(decodeRuntimeIdentity({ ...runtimeIdentity, buildCommit: null }).buildCommit).toBeNull(); + }); }); describe("PreviewAutomationError", () => { @@ -203,6 +225,24 @@ describe("PreviewAutomationError", () => { }); describe("PreviewAutomationStatus", () => { + it("accepts an optional broker-selected client receipt with a legacy null descriptor", () => { + const status = { + available: true, + visible: false, + tabId: null, + url: null, + title: null, + loading: false, + selectedClient: { + clientId: "legacy", + connectionId: "connection-1", + requestId: "preview-0", + completedAt: "2026-09-27T00:00:00.000Z", + runtimeIdentity: null, + }, + }; + expect(decodeAutomationStatus(status)).toEqual(status); + }); it("accepts old hosts without viewport data and exposes it from current hosts", () => { const base = { available: true, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index f87739670..3bafc37f9 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -63,6 +63,20 @@ const PreviewAutomationTabTargetFields = { export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; +export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; +export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); +export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; + +export const PreviewAutomationRuntimeIdentity = Schema.Struct({ + schemaVersion: Schema.Literal(1), + runtimeKind: Schema.Literal("electron"), + runtimeInstanceId: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + appVersion: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + buildCommit: Schema.NullOr(Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/))), +}); +export type PreviewAutomationRuntimeIdentity = typeof PreviewAutomationRuntimeIdentity.Type; + export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, visible: Schema.Boolean, @@ -74,6 +88,15 @@ export const PreviewAutomationStatus = Schema.Struct({ viewportSetting: Schema.optional(PreviewViewportSetting), /** Measured guest-page viewport in CSS pixels when a webview is ready. */ viewport: Schema.optional(PreviewRenderedViewportSize), + selectedClient: Schema.optional( + Schema.Struct({ + clientId: PreviewAutomationClientId, + connectionId: PreviewAutomationConnectionId, + requestId: TrimmedNonEmptyString, + completedAt: Schema.String, + runtimeIdentity: Schema.NullOr(PreviewAutomationRuntimeIdentity), + }), + ), }); export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; @@ -565,11 +588,6 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ }); export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; -export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); -export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; -export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); -export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; - export const PreviewAutomationHostIdentity = Schema.Struct({ clientId: PreviewAutomationClientId, environmentId: EnvironmentId, @@ -583,6 +601,7 @@ export const PreviewAutomationHost = Schema.Struct({ * a newer server safely coexist with an older desktop during rollout. */ supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)), + runtimeIdentity: Schema.optional(PreviewAutomationRuntimeIdentity), }); export type PreviewAutomationHost = typeof PreviewAutomationHost.Type; diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index cc8c155bd..056879afd 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -60,6 +61,7 @@ import { resolveWindowsServerAsarIgnoreGlobs, resourceMonitorExecutableName, resolveGitHubPublishConfig, + resolveGitCommitHash, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, resolvePackageManagerUserAgent, @@ -2483,3 +2485,38 @@ it("ignores trailing separators", () => { ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), ); }); + +it.effect.each([ + { stdout: " ABCDEF1234567890ABCDEF1234567890ABCDEF12\n", exitCode: 0, expected: "abcdef1234567890abcdef1234567890abcdef12" }, + { stdout: "abcdef123456", exitCode: 0, expected: "unknown" }, + { stdout: "g".repeat(40), exitCode: 0, expected: "unknown" }, + { stdout: "a".repeat(41), exitCode: 0, expected: "unknown" }, + { stdout: "", exitCode: 0, expected: "unknown" }, + { stdout: "a".repeat(40), exitCode: 1, expected: "unknown" }, +])("resolves only a successful full Git HEAD: %j", ({ stdout, exitCode, expected }) => + Effect.gen(function* () { + const commands: ChildProcess.Command[] = []; + const hash = yield* resolveGitCommitHash("/synthetic/repository").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command); + return Effect.succeed(mockProcess(exitCode, stdout)); + }), + )), + ); + assert.equal(hash, expected); + assert.deepEqual(commands, [ChildProcess.make("git", ["rev-parse", "HEAD"], { cwd: "/synthetic/repository" })]); + }), +); + +it.effect("reports unknown when resolving Git HEAD cannot spawn", () => + resolveGitCommitHash("/synthetic/repository").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(PlatformError.systemError({ + _tag: "NotFound", module: "ChildProcess", method: "spawn", + pathOrDescriptor: "git", description: "synthetic unavailable Git", + }))), + )), + Effect.tap((hash) => Effect.sync(() => assert.equal(hash, "unknown"))), + ), +); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d033ef099..ee34ea032 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -823,9 +823,9 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( return { stdout, stderr, exitCode } as const; }); -const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { +export const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { const result = yield* spawnAndCollectOutput( - ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { + ChildProcess.make("git", ["rev-parse", "HEAD"], { cwd: repoRoot, }), ).pipe( @@ -840,7 +840,7 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo return "unknown"; } const hash = result.stdout.trim(); - if (!/^[0-9a-f]{7,40}$/i.test(hash)) { + if (!/^[0-9a-f]{40}$/i.test(hash)) { return "unknown"; } return hash.toLowerCase(); From a2702bd5a9c0918e6eaef1225baed081ee04fbcc Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 05:06:04 +0200 Subject: [PATCH 18/59] test(checkpoints): give replay fixtures linked worktrees --- .../testkit/ReplayFixtureWorkspace.ts | 66 ++++++++++++------- .../testkit/ThreadFork.integration.test.ts | 52 ++------------- 2 files changed, 48 insertions(+), 70 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts b/apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts index 0b088e0d0..082e50a0c 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts +++ b/apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts @@ -41,28 +41,50 @@ function runGit( /** Workspace-relative path to file contents, committed with the initial README. */ export type ReplayWorkspaceFiles = Readonly>; -const makeCheckpointWorkspaceEffect = Effect.fn("makeCheckpointWorkspace")(function* ( - fixtureName: string, - files: ReplayWorkspaceFiles = {}, -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectory({ - prefix: `t3-orchestrator-v2-${fixtureName}-`, - }); - yield* runGit(cwd, ["init"]); - yield* runGit(cwd, ["config", "user.name", "T3 Code Test"]); - yield* runGit(cwd, ["config", "user.email", "t3code-test@example.com"]); - yield* fs.writeFileString(path.join(cwd, "README.md"), `# ${fixtureName}\n`); - for (const [relativePath, contents] of Object.entries(files)) { - const filePath = path.join(cwd, relativePath); - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, contents); - } - yield* runGit(cwd, ["add", "README.md", ...Object.keys(files)]); - yield* runGit(cwd, ["commit", "-m", "initial"]); - return cwd; -}); +const makeCheckpointWorkspaceEffect = Effect.fn("makeCheckpointWorkspace")( + (fixtureName: string, files: ReplayWorkspaceFiles = {}) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory({ + prefix: `t3-orchestrator-v2-${fixtureName}-`, + }); + return yield* restore( + Effect.gen(function* () { + // Keep the primary repository inside the returned root: async callers remove only cwd. + const primary = path.join(cwd, ".fixture-primary"); + const staging = path.join(cwd, ".fixture-worktree"); + yield* fs.makeDirectory(primary); + yield* runGit(primary, ["init"]); + yield* runGit(primary, ["config", "user.name", "T3 Code Test"]); + yield* runGit(primary, ["config", "user.email", "t3code-test@example.com"]); + yield* fs.writeFileString( + path.join(primary, ".git", "info", "exclude"), + "/.fixture-primary/\n", + ); + yield* fs.writeFileString(path.join(primary, "README.md"), `# ${fixtureName}\n`); + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = path.join(primary, relativePath); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); + } + yield* runGit(primary, ["add", "README.md", ...Object.keys(files)]); + yield* runGit(primary, ["commit", "-m", "initial"]); + // Git requires an empty worktree destination; repair its backlink after relocation. + yield* runGit(primary, ["worktree", "add", "--detach", "--no-checkout", staging]); + yield* fs.rename(path.join(staging, ".git"), path.join(cwd, ".git")); + yield* fs.remove(staging, { recursive: true }); + yield* runGit(primary, ["worktree", "repair", cwd]); + yield* runGit(cwd, ["checkout", "HEAD", "--", "."]); + return cwd; + }), + ).pipe( + Effect.onError(() => fs.remove(cwd, { recursive: true, force: true }).pipe(Effect.orDie)), + ); + }), + ), +); const removeCheckpointWorkspaceEffect = Effect.fn("removeCheckpointWorkspace")(function* ( cwd: string, diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index a2e2c87c3..0a8029bda 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -11,10 +11,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; @@ -28,6 +24,7 @@ import { THREAD_FORK_NATIVE_TARGET_PROMPT, } from "./fixtures/shared.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; +import { makeCheckpointWorkspace as createCheckpointWorkspace } from "./ReplayFixtureWorkspace.ts"; import { decodeProviderReplayNdjson, materializeReplayTranscriptWorkspace, @@ -55,50 +52,9 @@ const CODEX_READ_ONLY_NEVER_POLICY = { }, } as const; -class ThreadForkGitCommandError extends Schema.TaggedError()( - "ThreadForkGitCommandError", - { - command: Schema.String, - exitCode: Schema.Number, - }, -) { - override get message(): string { - return `${this.command} failed with exit ${this.exitCode}.`; - } -} - -function runGit( - cwd: string, - args: ReadonlyArray, -): Effect.Effect< - void, - ThreadForkGitCommandError | PlatformError.PlatformError, - ChildProcessSpawner.ChildProcessSpawner -> { - return Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const exitCode = yield* spawner.exitCode(ChildProcess.make("git", args, { cwd })); - if (Number(exitCode) !== 0) { - return yield* new ThreadForkGitCommandError({ - command: `git ${args.join(" ")}`, - exitCode: Number(exitCode), - }); - } - }); -} - -const makeCheckpointWorkspace = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectory({ prefix: "t3-orchestrator-v2-thread-fork-" }); - yield* runGit(cwd, ["init"]); - yield* runGit(cwd, ["config", "user.name", "T3 Code Test"]); - yield* runGit(cwd, ["config", "user.email", "t3code-test@example.com"]); - yield* fs.writeFileString(path.join(cwd, "README.md"), "# thread fork\n"); - yield* runGit(cwd, ["add", "README.md"]); - yield* runGit(cwd, ["commit", "-m", "initial"]); - return cwd; -}); +const makeCheckpointWorkspace = Effect.promise(() => + createCheckpointWorkspace("thread-fork", { "README.md": "# thread fork\n" }), +); function readTranscript(transcriptPath: string = TRANSCRIPT_PATH) { return Effect.gen(function* () { From 7676f01cbf35478120f0fecfb86afcb8f2cf06c9 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 05:12:06 +0200 Subject: [PATCH 19/59] feat(desktop): prepare Jones Code branding --- apps/desktop/package.json | 2 +- .../src/app/DesktopAppIdentity.test.ts | 4 +- .../src/app/DesktopEnvironment.test.ts | 52 ++++++++++ apps/desktop/src/app/DesktopEnvironment.ts | 4 +- .../src/app/DesktopPreReadyPlatform.test.ts | 2 +- assets/jones-code/jc-macos-1024.png | Bin 0 -> 631950 bytes assets/jones-code/jc-mark.svg | 13 +++ scripts/build-desktop-artifact.test.ts | 15 +-- scripts/build-desktop-artifact.ts | 10 +- scripts/export-jones-code-icon.swift | 90 ++++++++++++++++++ scripts/lib/brand-assets.ts | 2 + 11 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 assets/jones-code/jc-macos-1024.png create mode 100644 assets/jones-code/jc-mark.svg create mode 100644 scripts/export-jones-code-icon.swift diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ddef3ca9e..bb0ac398f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -40,5 +40,5 @@ "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "Jones Code" } diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index b486f21c6..da1226442 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -290,8 +290,8 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.deepEqual(calls.setName, ["Jones Code"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Jones Code"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); // Packaged: the bundle's own icon stands, so a custom one the user diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index b5aabf253..167d2835a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -40,6 +40,58 @@ const makeEnvironment = ( DesktopEnvironment.DesktopEnvironment.pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); describe("DesktopEnvironment", () => { + for (const { channel, appVersion, isDevelopment, displayName, stageLabel } of [ + { + channel: "development", + appVersion: "0.0.22", + isDevelopment: true, + displayName: "Jones Code (Dev)", + stageLabel: "Dev", + }, + { + channel: "stable", + appVersion: "0.0.22", + isDevelopment: false, + displayName: "Jones Code", + stageLabel: "Alpha", + }, + { + channel: "nightly", + appVersion: "0.0.17-nightly.20260413.42", + isDevelopment: false, + displayName: "Jones Code", + stageLabel: "Nightly", + }, + { + channel: "preview", + appVersion: "0.0.44-preview.20261002.36963972634", + isDevelopment: false, + displayName: "Jones Code", + stageLabel: "Nightly", + }, + ] as const) { + it.effect(`uses Jones Code branding for ${channel}`, () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + { appVersion, isPackaged: !isDevelopment }, + isDevelopment ? { VITE_DEV_SERVER_URL: "http://localhost:5173" } : {}, + ); + + assert.equal(environment.isDevelopment, isDevelopment); + assert.equal(environment.displayName, displayName); + assert.deepEqual(environment.branding, { + baseName: "Jones Code", + displayName, + stageLabel, + }); + assert.equal( + environment.appUserModelId, + isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + ); + }), + ); + } + it.effect("derives state paths and development identity inside Effect", () => Effect.gen(function* () { const environment = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 5f4d64c90..7d322d25a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -94,7 +94,7 @@ export class DesktopEnvironment extends Context.Service< } >()("@t3tools/desktop/app/DesktopEnvironment") {} -const APP_BASE_NAME = "T3 Code"; +const APP_BASE_NAME = "Jones Code"; function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; @@ -115,7 +115,7 @@ export function resolveDesktopAppBranding(input: { return { baseName: APP_BASE_NAME, stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayName: input.isDevelopment ? `${APP_BASE_NAME} (${stageLabel})` : APP_BASE_NAME, }; } diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a2d4483e6..ce49874fb 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -117,7 +117,7 @@ describe("DesktopPreReadyPlatform", () => { const identity = yield* Effect.promise(() => portalIdentity); assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); + assert.include(identity.desktopEntry ?? "", "Name=Jones Code"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); assert.include( identity.desktopEntry ?? "", diff --git a/assets/jones-code/jc-macos-1024.png b/assets/jones-code/jc-macos-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..8b71e56f912c7ffe75775af974c4c103b71bccf9 GIT binary patch literal 631950 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

7e; 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�WRHj=j5nb zygoYi96$AAXXL3a=NX%>L%WOriCk8ecI~9l45c9O9Zz$Q+|rz40gZ2%5k`b-@e2^; z!fSxStuGloC)sgH_2kVSYxOWSt|#B=)J1~gqINwnFJWPJ6df8Mi#r%cj#OY$IdJ(NA1tiQG}s|>ir+cv^?H@$+FSJvX|#4m~IiqjS9 z)Gf{0BsG`)lQNjz+^~q=SrG-+8qi1Kt6TW_zmpzkMaZ$STWUD)j~(xP7#b zZ)6JjMEHK4D6St2pt$!2yx+QcE^AI1N0-(sC7m|h1AtU|KDk72F0)kUL8&Ls+-kCE zilq%M#X|!%y(|1muq(H^BQJ6=jGSdPQfk1uamjs+=ZliSV3}?Rn75;m-Yz95l_HqcKdxQg^Ul@Q zQL)p;uv!h6AtfoM-cfr_&#`6ctwo7Dbls}4-JcYXOJjw7 z-6GH4M#I!f$5-H~gM~QhW=-hP*dy8L*KlBuyQ9tb&?LfAJ^~xIg(~P)pn9C0B-My@ zwKi}G<|se-33Bf%0J~XwCnwkXE=Hnv77LC>3?PqAUM~Txw->yTyUHEB?}$3aIa(yJ zUgS)J7Q%+iE)tGX^Y0?hQW@W+8j_8#US)pI^}$J3`F*p|<_!foR!v_Saz3EIft01}7g`uy1t%@{aARu-R9sMhiqXTX$Tv=UDrt zS|rZ=qsqcldYnD!k+Gc)cCa$I+i+{p>CMviye?~W>7(hbGk5l07X^&#_hStaKI`Cw zLQNaKEkQJC5zG*KikzQdZTaLI^TyMl@iazHDwvqcg;bn2F{WO`)PT`WhO|uV6X5fTa05;7H(<#b;wNqufvoZHd2fv zgGu0JOEDV}P(pR1%nA7VZ}*T9Q0OD|N1*=r7up)pYbhSgrT}isgtMQsxe6vz2_fns zWb3dpxl!8ekfOhO2l5A16#X{BHLmRf3AxA0w>S z2c_ixwlXR>ki+4x2O!_}?oYQAyqla`n(?xw=hA=8Ct*Z=I*Ap?9{BOy58B+q$GJ$^ z_viz5Yi;}jYda8tau#D5qAup}^wOEntLE0&Ipyd94oSvv6yVHt*Wcz}PbL+aYF1e= zqqwUdQb)c?y?=n$7z~sqebtK~c(WE*O0aJ!DwYOLr7wtaFW|E|^{5!I(Pz__QE7_2 zK&|C1QxNp%{{F-DL1*&36Kh?H7G zaL@Lxp_Nn)!eo9s@dM>&#B0aO21giG`7K#93e|~E5jvkgl#@o*wkOp>E68Oi+0o?gLLYd`QOX-Iz$F6NVu zkuYa2W^Pz?eH^K!QFh!t@rR?$U@S&J1HcbvUTv7f(@MJ29XUF%@1~G!s z8g%`g?-bKRbK2)iPDZ(w){3ZY*`ZxjBZOeguU+n45(Kf7U7r~!DP4p(dc^#G9Wd$Z zSFm6Lp;Jk1p^0P^wSh!R+;@H-LRz?fG=9^d_PuuAdCZxn8ybW^H`JeE>xV_30bGCnN80Q8@D4~eJI?>&0Vg!4Ch*EU znFtQH7pm5Dh%=8*MWNZY7Zrr9`RK>EBH4ew(~FdEN!Wc9cm^ttIwJ^%G`!dllo}Q5 zpNx=V?qUImbN;G~8AxsS3+X_UK)xo*w*PpfOsf9CZW6n+kR%KbW_-2Dk^c`;ZUwqxpIm6WG_!1Pc zKGk<7zpDA=)JH&O0el**u~-geKc_CN`f1li{gvpefK22AVO~{@sSkXoKy?ST{C&?RmR^l(ftQYN zE~P>(_N;n|;+KyVG+-uRs`Vko;xEQPUDu3|jRHB5fMMqkVXBLw;&ZQ6D0klHrGvEv3{j5_ z-j!5=rpQXH-Cn~~*;3zBkM$x-qK8eYK(=z6V^k4aDml#YyM+*J?IUZlW0PSV|B~6S z2-Sr}NS$=z`@Nm(9QT_2$)s`8h4fz+la#>}7^84^3hh7CKsvh9VZu!U3BdE(g6<)JV?qc2%a;tx|}ACB|i$R-tU`de&0bihj`j1yd{X)t_Hj5zL`!!eS&I)b%QY)_G$$H@0Y{ zM!O`*FVSOlJ&c4DXyN^-L^0TlL!^aLSb1TXJ)LUl*r%zLfEC?(ZwCs|#n&E06O>fZ z!qGVAhzfX@3Vul_fJg8oF|;h8pUZehstx|3`-T{DOfb3sa`;#h!Q1cZ!0eK)@5p~s zn2w}|Sk8@xuYJl-7W2_vc1L~eSDUpbEW@UI2q@|nH{H7eyH#N_vjb+M70)Y@`SxF< zag#B0$Ks`ia@nmYDF>%3cs`KEWieCCJ^APP!>M-Z;i*Pb^lr2(NoV5K?)k|VfpGRy zO4om}QmQrqrwpCi(CV6IG(+Lm%oew+>Y)+z9m5|owA)38*Ou5y+Y7CNjO`(0RsKYx zl1ZhAZw_TF$&aF_;qI`YSqE3_OY)!S3NgNZFT4kS=yAPTIj4>!ZI{UC6GoLZSvuT- z$~@7*s0b!SqN{|6D+Rb{na17x~cg~56ZcOA-8Ga`3JzUL|AuJ9ZUGaHX z3E%lD4c~Wm@$>acOkwXN5P>|-fK3$@!_!y>(08)}-d9`Z@PlvjDwz1D=~?A4rCCr_ zVu(0Cf3?jUcy}T!r*jtUw{Gy|=Mg!m>U6Jul!~s9-{flxJU!=CjYAN+HFoJ?X4sBc zX0r9dgtK3DqT*a%dkx_kd7q!7V@%#YXSP+-ez`eI@p9Lh(;8tzMl0ma@vonn^Nzl- z`v_S<9MSxochv5*Ec4IBb-ecZr?#}~A4-?+$bCFtO9brZz&=Xggi-yDeLkLk#Swak zg=5`xHs7)++bBpod-FB#rGwFoVA=Qt$;(Z(1xu62$|k9!`Fq1@O531HjP3CNG`Gnu z3Hr9l(QgVjuOF;M$?_V+JWqO-^ok1155?8?UAVU17&Qd6a{Q;Z;HeUrRb0Tx^?M_j zd#w&QtF*4m=rHBdT3J`7>&JFj;!}`xi|JTIN09p_NR$enEs#9{SVS1?DW@VUW=#sv zc=83K31YN32bG5#G4gMCf_5qlh%$zS1CEMhW*Nq5n}^TA@9+0xR2Z90DnQ$+p2r$$ZEa z0@XhjQVM64F(rr6EryP{ez4GKyP0nHEs&Ns)HQZ#z(HjW>`PZ8sHDAdN%ZG4^0GV8 z*Nkhtr#XDSn7RUJ!@{R`pTz|x|B`WK!+0v*@uPof3!xDg>%n*Sb|@v1M89I(T`Kkr z$&xVxXB0tTz~UHjZX zkFMDudMC=m!alBYNhwG$>aP=eS)cW->}BCYRncBhBV*un_=zaIQ6$|0iY^$#pqpuV7J__=UF1Kx?aDWyO-*+4_{U(A802r?8zz3_jVlJ zvTRqP5K%X`;Xr0&8g?^f3DWbl<=8W35~Oh!P{|MP#|fB?>u$DyV!&gYo1Yixk4E{ zzVaeI?mk=9_`HCSM60k~dSABD37oJE^fpQhg9uWs(z?>Bu3@1c6rjG_$#d3Ls~v42 zh^Z=tPK(yNC`i!jYs`Zew!Xj-ndD~4PSmOC4ew9m+~Mb=Pl}q7K6iY@#H&!#_d zH5CBElhkt55>jOWz2Vh%JDF*gslU;f#ho8=KmGD%>x&QV&_AD1%IJ|=tLN&62xJJd ze!qJ`Ukgs0@Am!|$#AH49QmF%lo#=6=l1*y6il5(?=znh5W+0bUkO8{enS?$c{lVK}_xQVM7f%Wjq z!usi4cu46wU?8&WHpz73?M8ELa2r(ayh@yjsSCvEVOFFor(5LKRBn;4de;bAp@d_1 ze-Mo-ex3lFXc_~Dunl&e6t^GbG1J5LI+VDbm1}-Cf=LFEg2=(mN)^sI!qTIN#{}y- z@``^}z+9-={bMB+PFWBjRBlUK#amh7j&riJB`s7Yn-#~9(!E7*g~sKZa%~dTZ#_%N z-HVy(9bH1yl80k-svm$fL|iO>1IKjy@+wkSyfMx9>3m?)7csY6_b>ds}{U8Cnxz-?5874d4|_h1_8U( zH6&t|(iM?6Uks^E$$-c2Zs2!YgTmI%%+_9al#^T0krnd6TnoU~%WVWXlQ?h7+THW* zNIBcL?$6(bM6ZBvf8ayyT;WBIiW(Wml{)1wG;oGV!@uRqMb7Mr$i?XDU&hZjo{*jU zosHmzJQ(qRzI7Df$7r5ug>SapO5G-Mii#e4nK#U^n^yty>V1=#ofZV7Pr5$}61}~G zWz;7`5#7EAY&Z4<^UN>&5Z~$Pe8C;s2*2ELJG=XZ(AjwNe@%?M%jKe@fV&Yko!ftl zn0yg5L&k?3c%gVN{W9v&@Nw*XIC*33^J@L$@AZN`GDv0g*bhw`HT;Zr$?pNa%Om_J zsk_T3kQuO$-fPugc1U*IPsX*+V}6p28!?+oU2f=iq33VJ-|(u&4X2-Yqa;5Tc4X+nWVmLz8jg~)Nvo2@94Q;|jvQ%=b_x+D3?AqQUZ--nB4Y%xsLq7=D)B1F z86doV;Yx=jDPD;6K3h{m%fioOZnu6NT2;XNJ-#d_)oXCd-pO#E zCwlm+KZ0-XXJ5>iY-853Pe32mC>u0KQSQgPeraj3P$q4&`(s&gMZHOan2$T`gIvjchQT!Z~=Uh0qt zj{Hz*heKshp{CMMvO9j#miuO zseysShQ}$?dZa8mZf=NQ;%^vpB&YdPCu^(>@5_g|fEQSq&-#;i$*2drAyQA-#OdOg zA3!ioV$J9j!zypU_reWY%=l54GktRl-W%u)^(K9}{3r7L5+4P4n>BAg(R2~wL-Rk% zo!rIwp9~(9=s0^^Y_jR`xwj2Sxg#wz-GLvw4jM=Qv{fCXfw8&pzLHL-rj;b%?rfhd z`1pslQ=^2VpS-zc zFEO$pl)q&RFyp~XEZ-%v`lpXBfGx+_p0rv9}isRc;hca z;ULoPzMf$6qB}6Xi@jPl3vcv+GkIQm>3n;|U@`(|c8xQ?8>g6=QL`vB;M%Rzq*tn# zMfkbqlq_4P13?n3t4Kf>a8%LAa@6Phl0h?eCF$O~pb(X1<1orHuFU7bD?5!Y43?+z zqSN5h!sE}LP+r^bbREC{cozgYoc!gCFlt(-+X~m2c2(@rg5&CHacZ|#@O(g%(@(#U z?-xpD3l}LX6HSvxZi_O9kMZA)-I|QNh1_WiY)(+cn+dfG-naMh^VEAka9s_4aRW(- zO=)o1QC|C?d2r>LVQ*{~V9Y#u?ChH49yTIP#ECL~t@XeTcsar=NK+%Dnz^RAt2I~L z>awF3iu|W0Z%Ga(dGVKWH&HR|a?Q{IFL`(PlCQwLpFoNWc#m)}m7)8BgB7)DZTNETUeZevbP3p$wp3jb2Wz0nd7`C$Z@O}PV{@@Lmlyt z3uR8AXlrcHar?O->$5m)`B3E0`v|9xl-LZJT}4Q61WkK;kp54|t$me4ywNB9F1`xv zd{aN4$ld9s919__M~UO_b%tkgTQ7=AAS)X(Bzz(p_`fy!pS+TYlvccVGiLr!#9n(^ zR@)p-DI-Su*nKEe)r+N%|Ki=5jj=+2WXmpedTR+Pm zsVeq8YxQlzk9}n&sfRM|!p{<<2|)*drhvK6WUn`}AjDu)@&{6uSLEr`iy>eb7ix+ub1-iMX@;a!#x^ z@*`%BFmR|hc>b4nQxxoJ(Ku`N=6m%M(Lyvt3&-#9;A}wc#{%Q2GW#=^;-~|JZ!m!zx5mgpoZt|@BkB)$+W*k! zIM`i|S*pZZaSPzTEXVcIZ13;~K<^g;TSR_5rr;H+<3%lCQy`Ku(nQR^Pl}AB-pVby zF@thEr;c_mG=BE8UupXi|0}$2T+R0fs);ui;^wBS%V+9m8MhB7B~Mry#S}h!*h~HA zkS%rYHwb=tQ^>?^DJxhb@Sa16SYKGn`1c6Ka4Q*>kE489`?fhp2vI(m)PJ_|%3Ry3 z?q@(^Cawk~?%m%oZ9(^ZiCF4-SymVMg5wz@Zsc#0^PeK)Le?Qp#i%x*ZuPuP){vG$ioqnNu-Mn6m#Ejq1`&nbt;*wrhufdC+FOz!CEXOt(b`~V($E{|(Fi}B zxr1mMky)Y(x1wcHPE1(2_awRU6blP#jn@tj5qi@+>YRvlIp09pt^S=Pu>h_hAf)Az@Cv}1@gr}mO}Uo)eYW< zwmPV&$=ZS}ankECOH_C}d20OrHrtWJPLLkdE3 z&v{>eKhlE~a?{+K-8~GAdE`ZWg7}6-^`kg~=dZ}@Uw|C<{JMv5Xc;Y`J-HE$D0`ms z1|4E~aec>z^N45ZQkOAzE2wgypz!dGAJ=m2IeRRu+!+aH=sjnd-Fg)X;-JQtKT_5c zS3K%N%%a6Z;@2ou2?ElU2Qmc4TY&`%w&CSMq?peJ`&&JB?%XM_c$%4B=p*+Rg(8?1 z3F7OwXA|qG6tx^0n5C}aLtq|lt?CR%5^^?I1)0Nowg>T_+zW)W(Vh2@%~uAF<_c*> z|2f55BI7iwchAp6kE{oK{P`hKaF*07nS(S>>8bh@?8k$>N(s7`w!GYg2MlhY6rG_4 zddMqk0Q4x+#R(Ib_?g(*&CsPfhkwC$fJ?kvP3YZiQ7@_ExHO#WS+AHAF9f*9@qhOF zxC>AjkK&zRqI1edEhde#|%052{>>4yi?sB#4)6yPn9S z>w*&(xKFh0a#(CQu`cq8N$8CkGae|QZMT525?t@!<_%2JpfYJvCbZNiW^<&No?Y_9 zR{+VPcedrq472>0w{C0dZJqrtlLs>_(M-hHs-O*?#IIR1hy8AKdI#n%*;o@&0cLp3 zFta>ZSH0(ByS`Jshj1> z8fouMw})nGXKiV$aaxt#WWuw%ycpZM*JC#4Jd#ez95TP7FMQ6kdNIuK=6qNn{_M#} z>!{Fxw{P{>->si!11df=yosz!sR^!7JFwL5IY2Ji^Aq9a7=EQwL)T@yaCp>w!IH9^ z^JnwS=ThiX%TGM*KSo>A%DqU7ZDmz$WXs`)gBBF;PA~BVw|?TVhnEZ+rOE1+d;!Mx zP`90{js>uu!7_NC!8byj#5|hVxts9g-juvB)b1~mSSJ?y9>?-vc~#yGEkXKn;#i!s zV1B$j1Kq?}K$fV3SuB+=?q2CLoekAY8Pge=>1NYOV7$d9D$4{z>$LW@T6Db2zYY;w z8xBBjj&@mk+IkLVrX||MCKc$CqrIjbDNCQ3j0bA>Ms3%pvD5MZQqW264{ zL-J^1k z;l(0e`@h!rA4`?q`LjGDO5Y(0uokt3WotbpUQD_#ZfO7i-DBq`VKS!Rwt zpKC7nz3Q5-N;opvZ!%EkjXyBwqx>lpYr2r3Ql^$aqvL-61oR;Ox#CPBUjzJCLzUiS4`ZN$wJ zF|gHT`=uqxQ2gZG<1de`bN+m)FX+|(we5>grhjx4URFR7U{@(YO@@Ave|=pI8=}Jp z-&wa|O!mO#P++tuR_x-Xn{WKn-(xu#Me19f-}5c@U$eJO#e)&Q_iCZ%Eiy(k+9;lm zf@M;;mWL5xs$H;=Yo%th61QlRGaLM(7is7h&``62wXOV8q3>v-ApCnol0X4+H z3W^_Z;%{umVhBZ-BimgwOepWsyc)7G&c-S{USRW6pvmke+rFSIEouH}s=+)uL|J9f zAj}1p4XbK`ycd|>Om5V_WDrC-E~7%8P$ zp}wwSq#ZC!@!(F@%F*^4^VFF4(6n@52nziVM%71ox9VUIfXFcZzOFZCasDW}&Gz_D zMjnZnRh6#$?gI%H(%&?p`P2Z?=0}@@U|UxUTq?Y(=fBH&j12|4*I&lrZ+c9(o|w6A z)|U2eAF@r6t_pQE{zRB_Y~I@~d$Q5@@_YW3Z%?Eehn;VI9>(S#Up2M8D@`s1HtS1EqcNjLIL~Y3PX_#9J|b$8t$tV za{ECYF{<(SnT4u8&RdANl%2&Hb^tU%nFLS{{y(BqLXh4*Ih_Yv`-|uhB4sS@Uz;EVWQBDAmj@1+omw@V9Hz7sX2d(?NPdO28%vt-hZ|Yk%{Zu0E1ih~F?m?IS1~{*h(SYCeGKi5%vkgW z%3R&89go!5zEJn=X~|}+`Q|2Z#C6%4x*PYZu=62Tg;XK!vIGJ#mg-XWgz@HJy9Lvg z-9@(7UvVexq=$pO?1}{?Zni#(9DmO~>QQcP1EmZ0Cjo!7J@)FD-onmFltCm!%T+AI z_JVbohiH8YXr<;{p0rBFVwVgu^R3?`y)||2R!^~DUH2iaT(H7kV62XxUy1BK^N{pg zclHIBp4{&CoR5__oZMCt2N)e8&Dk89Y5U9Q_;Bry*fYyP&ASHoWo;U-JYAt}rwnUG zr*3KmlXB*giE>8wPGKd1uzet%jrc(WJ~Mzea|SD zr1S~c!|O~v*4H5sTD1BB1m66RJZagye~Ugbc1LNqja6^UrR)e-T#59eXe@)n@OSSG z%}QpRp$G&>Z8i;FlDsw@W~f}$GnMRJZn|B5O-(Ut;aU6X2bTu9&u5ry#FWQrUARoB ziEj$XpQ`E!K4FXy?VcM1VkvGSmN8`#BhZFPp)SOOXv6h`48bF5da9-vW;jM3ed_3_AL;ni3A1xLM}9kCH+8e$#Q+ZZ#j5SMFHN%6?p#%{ z_6yXdJH*RZb=?zfY=WP)j|<>a+*2BKvQli%Uyi-2U@|hUh}*K^R@k6wG6#f&H!AK5 zqHW1rGr>B*8+r?Mon#|w=$%Pz+V+ABpA9RnpWZ^0C0sJE{2!Y0 z8+y|xSvOUy)XdG&;my3uVf3*I0N`E*I(p+Tnq%=Vnqwp6%m_dCmR(B^K+<;Ze=-0gw8iu^^beKG?*uO{7a!5!kzIffA=qvWG(my}8vk zaugAhV>1Jcot%>rH4WAE*`1>AC)YM&CVTDs^5vR<1)nBVLepTeAD%Q}+sGn3*vBd|KILMlc^K#pZ~_}|93w*_O;?+J2gVEv^4GHGxsuOOA<)MbF%~4@l2k^x z_?a~9%3QeDG)X;|dvqqgV*#x&OZ_%}5S~-zHBunmRs1d4_7|G|wLj3#@Qa65k~ z@&8a83+fhsd|Gi;CPl^MsAe^^e9f zU^O#O;W-DLk&CwxHkA*u-L}!b$0c$lOea*%j3OmaqeJ6@_(p}Bg00= zsn}DcbuKcEX~mAbESwOUaSv z)Lr`D>PJnlfL;A8V@N`je^DE$YjoSyyFJ(@_(Jp(A2w`1?#)}%gDyl>f@^;8PgcvDi9f4%wynd#Lmi#0mTHN4}-MI>;=9HPDA zM4v`!2XJ(G=gZ-#tMT=k4mnM$Jt#9|O7KqU-8(JuM%3w9Tn9V`M28g(F2P|NIo=Pa8>b{Vcpq6PMk(&r%V3;-hIK zfpB!tL7MzZSW3fJF-!6|q(O*lgXa^LJAN&{3~&;BQHhncA#I`&@wql}NVUV zJV=Gwpod?cmZD6SpL^LpSg>uW5)gwoItJ2ykJ8rM~XEM@2Lr@HHloYqAWEL z&j|p)%m!D_PlL-_8qr2IO$^@l@o}U8qDivYCNXftZ5bHwOZ^cw+t=dV;^1&?vVVYO z0QHDw=;Tl}hRxih4|PQJhmw|33Ix)Kf^ux;CQU1&RsGk?19~ey&3F9diGMcLpE%*w zNNrUpW!BT{L&1peu(+SpZJ<2XD^ZG&5w+bCz`x&`&1#W z)G&}|Abl5#!1uyA~f!VZB9CM}{{Y0OvJmJVmUbm3u5KEZ|PCwdhb2dP~% z#GK@bGCD_k-fxLsUAGyUE&jzv4J?6-V}|zPdT|=B{{HBw^M6~y`9EOo!(l5Obc)PM zCom9V4MbcCUwWh4RJ}x4vdh4|Ru8ai|K&2Bail@wXG-!O`FJ#qKg|R;^abU=(2cFZf@WSv(LcEZ&0Vdz%_c`)P zUfVk9hF%NQ+%nZuCPAG~VPTYmas7KUwcs=f(vehuTChVsx+{g<0I8;pdOL!Y0;5(9 z1kOmRv+_Dud`&Ub$Oynlhr z+qIDAFWyQftJ(^KlKey$9YRJr@suKcd4}bX%$xMHb!6K)+EvOQ`|B?Q!8-P%d#c3- z@gtO9&;X3~rgg?9@EVr*M0rmy&Wq5tytXb3D(>; zrK8X@9EbZtYgQ5OG^5s}rX_s;6)VnTN zk7yLNwvA6MV3CiVousmjTJvvJT`0I8_f#&ZlwdtLQ5SQIB6$0erruJXAgwoYslj{8 zOgS!T5(h-0##7Jk9ZumbnVUp5TwmXzJaHyOZ9L!Tiv%IyV!MtfiQPgOV#!1g*)&%L zgH%WOSv3ziZl2>yv!3x!bzTi@uYxZr++3XK+n5=1F)#rY)Wnm~aIWT%;_AJP^SSjY z4G~^XNtk*iY{};~kMxgkDv3%0awn}RSYjT^CF>Hsx60);H_Qo3s6W0m{{Ge0l+Bvv zr(IvZF`ar2d6V@?T=4g+xbg@s)|^@Sy|T;J?9nN&g3M{-xdFAZDp_E2OZ|IL^pNpC zc8#+43MKp>w)jgvE&v{iXj^y0^bu7oh9(}ZxIi&`xbGZ$v;%S3Z3d8o*?Gv6iDC`0 zb|7n#G($u*<1x{3fA1I2cL%mmpMiSFX|#blsoOLLfd8nyl&QFT5pLnAO?Ga>FP5uc zYUZr)9tO+4Mb6Db{5w)ti|IrqT4C3l8x=&6OD9P^IwH6Bcre2|7yxSNerlat-`gq_ zdM#x53H4b3&!xRkYEj!`Jhu}eZ6O~%SI%v&Q%B;tO_ksrPq(3*l}2XU7V#_btb0&p z_iNq|P6^zy8MTuC%<_36X&#WP|6$yz346?&I*TPqOJ*JZu#r9;jF_byGHV=|ssL|j zH%qdV^L$TF-Ijx@CfWWp(WO&WJQ$T3^+!np{iDZ9eQ0AKv27IV*5!kqowN(d{`2G- z&N$H5lH(H~ys|v4C1Ku2(sO6l@+#;bgSMvo@UaRg<#NPv{%(MxEhKXr`t*$A_M)FIheBMtX!w z4Ek~yc>ji?k7mEN8rq!D2P0(nDZM+;!pa29=jNAzaInFB^_`+t#gt zCSmp)q>SIFbjgz{fOsz(b^BWs;@oS=5&|^m4AMgV%Ij^R7DZX+Dk*N;Um*jMY}E$B zSB(g*1l#Cq0*2gH-p1Xc41^E}HG{%T_EalpaIwRWlG~jKg_mHD{x2O*NsF`RO_pI|A_ydq4##HlyvrFf5m9O!N-)_Ed@R2TlO>KPbb5V^s-;Vd+F98>0YcZ)?QQfIAO&?b{mK=r(keGY(t8--xss#_xAFB!6faRf+Dj>fa%jOdMhebxj-GFFk&$24XKfbRIUdM|BT-^0}wbwP( z=xuRb|H5Lln!H{@u&y1#T~HC-nb#n1#}`~oM0$bnlN>ye;9bW)6U>}XZ#Rvr>R5)v z6?JMKgIIUGNV0MNr=yW-dR;NxXnFN9Tbp{4Ii(UuXBS ziy&Q=!rrx2P@$7j46T)ixfWM7Z2l_9o}%%2qEpZ>>RU#r;x-slHmMPr+rppiaruP; z-<8L{4GA`>vsw{Ek7t|d9olll;)0YIcaNUKKmgz@ZO03v5R2Zw4k-BlK@jw3=a3_iNsR^Ow}oe%2n$ z69OBLHX(KS|2#~G<*uu3na3UkBZf~v;tsbPKeL@i$n~i6cax!KM5nb`4yQs-skDFe zSs8DMYoeyfjE#<#qNUnOq7U*?>O!}1Q$(ey$FE2JoZH0L*l#w~1n;!`X`f*SK}gMW zF`}s%C&HmmnnM@0N`|oX3A4Erb`VtZZxFPu(k&$nxJH=pJYlYuU&Sf-;0Hbuc{1?H^KD z#QuPSr=62p2o~LXQnSS&2A#EbwhF`)=)5@nku=@uO zGf9{H+XES^I$_tcq23L=zo9UDZ0_-lWE-0DXd^aGg5xDSS&>aHrn`?)Hdo z&tE6(>!d!o=~8`##1}wE(hXVLgu^Uc7tJ1tU|$n)H)F9&(lmi4#y;UdVcBlTrW2Nm zvH;2erTB1LwOTTYu$w)Ea8|C8u-m4sN_{m9QhO*PyPWhI{*bqUP4UX2C3 zA4m@$Rh7!(R?i(#*D7$HZn)UZyV(E@=uQu=V5dOdMdyH4Unu(mRn;g^hSd|lr7cBe zqtlomo5_2WonJ06m2EHDMVrf*tg}I<2P+n@`FbJJtiEb3N}EqpOMf|}V&fDdOB(!B zBi%%HcZBbweV|>>K06ol*f^qgkvD$Hs;BQoZcB%xNTF{!6#K@#`CPozFtQNxK6SI` z65|4>NY^PEI6mwW{&wG_$lk&0*`U*KBQgL;>JSkX1d-0e6-a*bA0C8OT0d$~1&MCco6n3{YHc|8XdTONJ3IA`_SbhX@Tdjx!1SW(&GB4%h zc`K*J_=BSr%RFoKNss`q?!*gxnIS*4v74)^Q+9x`d`{Z>IfO1fM}LGpz5HbjRv=#O z(Nd(1`-ABYgHRBUm&Dm;0rzcPx`;J-YqKLbYTo!x$q95Bb*P#yxh_B|=<)T8%NOl4 z;$lFh)^CbG!kfKiT}%qM4o`_T+Kp)`2%lomKi~ea(aYnO?dg!5R(Q%1r)tPUKEtE6 zelxu<%-sG!phor(#%9`*3{Un5u0SE7X#oOE}7jVc2qDF)#~B?76aP$;Y}L=wDpfQ1Lba35oOah ztcEbzG0>OE22a38v+x=n%_!7m|mURP=}bCd`@+gsITlrak!bt z#IcS(eCQ#%jJ0(vp43^L(;n&B~rMb_UYQ3 zMkiRsUVQ(+%(_Vfo$uEQk6;e0$@MYjr~1|k%9iKt8vN%SzLA)_uf$M@!AF_zijS9# zM?QLYR6@@cf{~eGxgH_d8==$Y$$^(*mVd6E^OUz6fg{_;>2+J5JzIw>mH&L)jlXT4 zXslTA!upfCIo9qvvF!XzSAqq-*1Q=9hp%XN>n3Mh*~&`mcplPBGDx_a3% z<+6{`JzraE0Gra)HEIbeMlVkF4AxR#jDmD1cbZ)~J~ssTlSjxi2WLlZ4k8x*1gYfe zTP$2N@YFw5+dTgIG3w4m|1atyh-dG@F{-R~7b!>e=f&7p9YxVHYOEIiwa;rG<6vPd zz^Jhydfk0%gSRw3kuP#e@1ss2^qlRGM+GdmMU3m)C}!~1FUAsx&V-cgH!u&_S3_tH z`oRkmeHN9}v|pE7^+C1CJ5cgBP)_s}y;bjxI@we_x1f@J@Bm)j_IQ%52K&k1;++p? zYN+6Ws;{S|k+w(FI}&=@U7EK9?!O}4IK9z(S^+iiq@Fmvd_uNLZ0v-bvVPca1sIDM zl}W0xt8!6`&>UroI`8{zgA-kONG%rfQr8n*cIBzcRSn}GkK3V{%-ciCJMg`|9Rb32 zJk`~4#2L=XzWnc5RhB)%bF-bbkYQ6 zAS(#^YUbCMjE`ac-`-b%vua|*yyIKS@3{~J+B^_$FwYciILCl^sDP}(GsDJANzUnX7HPt5{wz>5}`G~i(KQRezN^rs7oUFsU>;AsWa#1Vv>YJq| zNllt9TPdH_O)oDeR5!dBggG}0_|DGeD%sYIj{v#gGx-1I5pE;ST{IS*d_>8rH`jK{hX>5oX z=C#1DTZIQf9AWr(H%@&=N!I^xRlgE4V~)Qn*yB1w?vmY}>)+mquTIbta^}lyPylS$ zdfYEQ@`yW>VhnqtCN)(63&y3y^xr$$*Oa$9HQ*^g2TWToZIQY-A_vwNU&kxx=G%lS zjz;vuugJU5vuHhCDha81vVOj)vF(}I!-FSIgWqu}t1;9k<=-Pq8iXF#mZlwB)3JG7 zc1Kc=_|Hebk}4JxhTn<&+2;bw_cNfl4zA7NU0;7$6w9j^NBsZ0-&l&K(B^xzg3gM!4&n@hbLX|0?P-Be7;*Lu+RbvgzpRLT|+Ux@AmVejK zU2CqF9n+tm2C@nv=YjA_fbYdeX`zdlsVg6L{Hx!t{>V*O*21&bOq2_6n!5ht)LTPR z)|-CLk~hKNSsMFE!qCE9B8a{}*|g7FR0J`c(F~=pt$H{u+4)&lsT()|K}sU9rtH}B zTXuSu(Pus9>St>wIojV_IX=-WVz1N$9vKJ+=FD5hMg95s&VtiUVO>r1DP?0wy>*KG zY<28~_e4vc38vpEZp!U-zNXgx^pXlBe*30DaN1JQ#>G9lYr%C$6g>tn9Ib$lKc#sjvFCfmGs`np^O|FPpGC9Fu7e}}9fC$fj%q9Kny|y8 zzba3;i*kBfF!j0T##$MaI&vsdZ=Dhs8?e>a&e3t~bL``FbMk9fCnlm}dLltaZYsZo(#AhH1@qV4t4{aB(J$6ZwO)fz;^r9%?Cfi_vYosd#KJ(t$OW;796v!dAAtb zSu^-Nct)A_H}FW%&Y4`KtuYsrUrnI5PpP;m({08@7(cN$?hllPPHL{!w08lHVkcRp&rP!;)a9o zrPEe1>%$Es{!;A5`Q)tky`)hWijQc&cC%*POTEuxGz$jq1#T3l^WQ;u{2i8N4+KF| zZw^}B;m(Pj(YH55TJ6fzm$VoUOj6DGWTaiK-HvYua*#;T40>Q_B8KVg&M9=zXA^N*$bI(vv?Q8v}4Rd_;-oZl!19ZE%>>-BQm<>eZ3jR zAuxVuWSU$ zBS`It46f(8Bv6e|EpGS(XgGM+$N?o@Rd?)ay>oZjXxDSkU-CuiJHhHvR|62^Rn&@BZ?fHt5+%hv?mnK*Hwj#*F-a+2cqkGcyl(e8lD60?|*a+N-t_gqUJ9WF5;xQKL2? z!YA8dOoyRpYl>prk)XU9dGSWFUgEZ?9%|>d&1_%v4CT%UNNaKQS?w9V#{4!iLZbxC z@~?`D1Rj07&GA%$oKc;w*?3mk@6P@4r_{&iAOI84pX`pN_d_66&65ZQ1VK;v3>D;; zQ4B^@@<2dat^KsZ(sbD|ZkBNXUh$~AVTf7WKwFzarMBhZ2Gnj?9qffY_H7mrqKW(3 z%>y)=K?ej;_ZMya&FKs>`v&QK*1-_%@7p={50xo!4p~Vage2FyhvMjA-@6Yw6-m5O zu^ok-mqImGK9GSC;oH2%8P^RaCM$i$RQ6G6j!q{IV!Ep0dAeqHj!S@>MXwfF2>qVB zTm_UR^T;7AXEgL+DzoJo1=|0Fp6EyT7+beW1<(rrj4)!)u7-cHpY4`pjd%RlAHm(< z;Gh#zeflE!-YR5A#6$G4`4l&abdm!+)%XHgn)k;bDuv6P zCU^(7MVT_k8mZy5-tQru3SN7i#jPZiKfMF@?uV{GTg#26<^6j}>wQ36(suKPY1^DofO^x%^mZCf zbBiJp!6nC&SsKZuBi<3(qP&m9+KE(tjL&GutQcKiyim1rnC5$Wrt79{0wL73Ik$Wq;yt#>a zsNlJT`-1iLZ?XXS2j1OYh+8`w=&^SfSMp>!HpH{e8d2iPH_%VgOY9H~&_irq zfGrciwUGeO>S^#;3G!uQ_Oe9kE=alD3&&os4f+U|S_m{iYibS=!|t7)TIWTlO{VS7 ztB4diwNd-y6(>fni5Pni?keXxHz}ObPMiu!6vH`G+|jfnM75F2uObZ}CpSE0Io+9|Rd-a>_Wc6-_AUDC9gi$QD&otPW_M$I}MZ6NS zoQLabys$5pfTh3Pfp{PxNYa0=>omTyHjqpl)2yM4RUr}zoEjCOhHBagIW*m|M~kS9 z)sN#Dj}z#{ZKhkXSg-h0zJ6fuZO;(K+{vc*B4}%Bd`#1WoHu)JjAmE#@LO4shXH`=5YIaUUx0xs{#d_Fjm*&D#D_2-e7t zjbC1>S9-Irl6=B~`L-kar^PN9#uyOX_$!asD22@fxnKwA$tvNDmBNe$ zfAXb>c%}Ed;-Djg!57Y+8|(1?g=F0BvzL-$rdUbPW?Edi!IO4G!1csgWqWPofyHm6 zHvb7-%^$1j{9b7(4tCCd6`*C|^%o-`rOS4D<&%Jvw3Rl)F1p_rq+&Li?E(#FWU!(( zYj}2V^ZAeU6Bnn1(N(K|U7(Tp`0mX|N|&&k1gq2GWvHLv^rvplS(5v6u#gJAXUy5k z%g*?7K2?!fgW`2o#A|iSNZOCu%3IpczRj8yR~q~KX#x!MKQoXwk(lhQ=R!G<_R|o6 ztii?{{ThZK&iO4f>m2K%ER*uXS(|R{j zi0jUAE_H)Q-BIZufw2_}c>&vhG9{VZvEcONcV$3(B@`a)=Y6w@jPIKOPUpJG6f7Db zMx!12))V>3fqB6#q$BTu1iGjh8wNT7-qhv9!0BnX4+s)$(WpNnOuX)y*ER39uIM81 zdkUz%I5rFf1wHs!+ydfbnRSqtiv~Bt8 zCVj7)l1(yj$8bNYxHe5U>1-Fs#g7I%GwRFxuM4!U@z(|FV7oxXk5D72^#LNYeGUix z&76bUNAAfO;CD}T$-bMS6bf`<Pkpx6|)dqn|RHDTCeo{HzN-($>Xk#(4 z6Per|jm|6SAvQ^&83MNFO}5fkGt!+?-XCOC&-$FhiOzblc5y^xT_^+@@?Q-RZwNeq zuu70iBF4Zk5I_5|hWFO_WqZamI!eK#QhY_9!k17rA$Z<`omslJqh6}L?2B>~D2BaV zC-D$|{~SG3unt7;G0GeqyqagvNwnz8ZvhUk&sDJ8WJ~_>9g=cfhx_t@Iz^HuYQ1u> zmg{#tl(HN-PFs&QJy`6=0c4ryB5vp@N%(GJJ86qUhN?>vb<6!NXL04BXx7{VarEN} zTY0a#uJV^#4o~Q;=6KWsZ~7sm|Kt3vKZM9+*%&OMe6Qs|!mU@CR^`iy8{FYQ3NYfgy&k7f0}cWP6+WQqO6jN<1Y zy`AV2LV|qOL1mY$G5Dw5Ek5R{?M{iVIs^ew(%yz=?#bK*3+59!R{)k+gXd1Vw@S27 z;MH(z9=T3ke49eu=B~&lK{Pl$bliojC9EUsDMu{|(ummL&k{ga(OmIr2hg&t<^vzf zHVLY?M9go%_LcdB&J2_4G=SFA<;sOkw240vD!&WcN+Kb|_)k`wS(F`V4o^dh?_2k# zfUq69PXgVciA1U|xcVU=#PW5|yBv+NHd7CxPFg4=BC(3Kqp2*s5A(Oa%mcvefhDj7 z!Ok~s&1h5yef$C8R74R(zFFtFG#vg`$9_amsz-8^O#Kaaky-k@kEiNZZ_kgfuKDuY zPDS?6Av?Pw$7AGbPXHQ)On=C30#EL>@nBUE2FX>_CX5CQF#=E+>@5<2u^oq+h0^@| zNTvJ+BFs4o(C7kYZ%(C2)y)zYe7WtbZweG5zyzePp(^bP}t+zHw4o zNgnD8UnV?-$m8FoI19V}a-d!3fs($yu&xBr)JnL?73#ySWJ}qfrH`7M&!SJjG%A-V zYq?TsKlZA}2UL;V?jIPw_e)oSlaZm;qT>M$)BE_1(Iw5szNsUrBB-E@3^cC(J>;e) z#*sw5R^*RXS3yHyyBr7_k{OR>3odX@QHAN{bAv@8|vfe7`?@fBF6q<8i;=Z}UUY)BVeB(sXLRUNhm?Dcb28f6kzjr|S3C1=qi5 z$Z6I*!KjH82lu<$gLp(~lDi)ueK96U_N%G=u11wt?}#SmSCL((VUH0jjF4_spMf5b z+XuU{ozhy$?)@mZUC7r?I_O^SVk)ci8z^c%Gjm6+MuZ%4=WceE^UH_Gqp!lPB+J)f zVb1q}-!r9}93zWX{?eh}+CBGlMG97vWAu>&MsV8hAsGqEI@qrlBEMMPndqQ;sz=QB zBa|n>tKGD5F&!%MEQ{wg; z=TbXg`bXA&dTm;{;LT2*mG6=wHMj#EM~2s)txlT`Bxr+ILS|~;$Po%r@GBLs%##Rqi>I8erR?us z$7)acB|>{zN8fAGlifY(wBnc6zugkXb*ejsEy<&)FzxGgV@glfk;zBkmgt;L4j%eq zmOmz-vV7z?#;t?wXvAw_^xVwh&@Q;dbg#c|^P9c=@to`!q-OeZ{O?N7Ea}MueX8=x zVdN*?<+w+Ii$qg?txMd!BlpaF&`;HQvjX*$d$Us?e->B^Tiatyp}r05Qz-8fT&r~P z(^x+O49V37$3H?YD=(f%)Oqjy(fje`t0`hzKT?Vp8^4b_sDi(3O`c3Xj(JwDS-9DW z+UNOPTJ&wzIj0ev^Y5h%0aWR}1>4?x3{CO9{3Dzt)rCtwuyeqlX=(T_)^grK)V{XW zoT6h{;_~?Tk<#7FIMZ!|l^QIIX2!;P0SlVcfPW`~K#7aanJ9*w?O;RU9!|?U{31b- z?$+>eIa=}wWY%BPN~s;r)lE4OzYLIv+$0u$ zWu?`DA2^g@mOCAJ*XsMaeS@bYuLWhv$<2$ftmUO*iJWo5tcm4(1lhq7YW|NqQu3Ok zZIj=k#HEB!XMJuS=lu|2dvobWgIQT{bK%LCdk;~p$Zlv3djmB8uKvNEMsL2|6hvWe zYmLMG{ySD=KMHTp@9}Dnl@_gAxdfd|g6Io7T{Fb6lsq9dW)q5ri;qr7a`I;ezue=<0 zXQDnl+eIw&gUc4Nfmd{}^vRI~tD|sA`4uWmeX)F6tO5}fI;Ry!H zJ&~3hgH(hseT~x$n$JF{u=h+zy3~3-Zu|K~Ix$l0g$*-p2pv_5F^k{6C`CS|o=sbh z4?X>x!?%WB)gSXZ+vGEv4iEV9Hi<0qttJ#Hm}2{Ed1!LQqEZOl5pt#F^E_kJ^heNF z{VZVY;KUTWmA^JvTbGKr<|=9A{RF2k!oqmAxH#D$yp!&uOG{Ur@r@SlDb{Xk{8xUj zl|wIa0)q&dHJo#r^IHw$O1PZJTKmr!)Xa`8h=pa64hW(2^^B6{RDvO=)?q*Vjapi} zgr6JCGLD~n$GQbBTDfFz8hp$gehmm`m*h-Ijcdw2w>_*5w{zbNZ?cK?z$zQDwNY!b zohmo1yekORDAB1cl5qd3Rp@7dMg&#snpjb`3FBj!M>LpJL#re!z8?=yTfewLk zz1@>ZLp4@l;v{UZb-Y?js|!PQPLCUnDQCvkG{Lq=(4eK-mn4zjM9UWRS?0wALgVYy zgcIjufB}A^0Z6BQxu6e%!Q?NCH(kpBY>|k2{3$YZF*c2JSTPDe?p<@b922a6t=s=; zgtqtjf{$6S04ew%GW2DMmi#eLD6Zgf-bCmvPf$4T%I(wR!j%Sh7-|OARqoNLV0qi= zJH?oCf0Aueyh4mnMVgX@^`scBg8`;&WH5}*B!acOy49{J;j zSj0#;rOej}w|Cl;u-=-K1_IyJ4(r69R!Qa*gMKxKe-CIAty?iH)qG82wG8DqB7ved zp;u@4tk5)GN6LI1zicijQSOf#>QB15@m$=@uPhZQiT3x+6jM<^ULH?*Xvof#Ys}6v z%c9JkET(2vEm9TPqh0y$yVbX?vxoH;lf%&Kr59a|AIlxdI@n*vMhF~g zU7xsF?e(%p`HoqX7M#2o*-gq7FvDLd(&;rCQMl)IkGKnX@$!t)dt)V###M&y`V`K& zhopUe(G$1icYkS?`W9-GAuV4t7ux0wxO#%7JDNX@fF2V`wm59DN=*A2<2C!?_@!A% z-%jMS-xY*$N$!?~9>!*BT^P5`qR?tlwlu!+j#NkKbtU(mgHD{ce=uts@MqX3ksz}6 z=OXUU2i@2vWIJ&iv_0RrsJBkn7%xgzLMwh=IMZR!4B?e~z|Yylpyu-8CkTLrJh_&l z75+t!HbPQ-c`T?9g}Zb4={^^|?W5P&;Un?z%m zWXtq)sYyN6=ST8l=Q>eyjxA>QZ0+g7mnPw*s^kw5^iDsbtOfMaCO6_3sJ0zUiA=0uC zqRD4QOBWWq_N$Ps`<=V^hu8GQA@xT+V8*1XYz5k8H@UD-;iO;KXFRp~EB>aG5<^}n zi>FSr+Ck{LMwxvjeHUgxU%&?Ssu}bel)sEL1HbCr%UsXOI;e*-gytzxQv7NizEzBiQR>*&=t4jc?-idU$CG6LdY9e!V__ zcEC#X1;a7B@tu%ugC81jsvvES5vQ#Ab5k$r`C>OD@1D-rxnDW>8xC_}SwnWoS>M*~ zX_~PXCVwsHN|H+7SdCd>MecXsmmSQ zTJtd0?xUsc-+EBctptc--jC@D9`$HBd4x8cx>JSd@#33-Qgyl&HhMZ+8$zB!5^J0C z(@rFn?9<-L0rPlBp?L11jOFWo0(2Nz9>t@U@gZ&R_$Dk|rznx-)1lO?j05$EQ~^ao zi+cHc=zsapG$LI?iKBLClL8^{J+U7Mz9L)?i2!{vCtPn4< z+eSLEl-Q>&q}=IZ28l7s-hFbxvj5^anS^&H%k8g2o5DJS@@MhMrBpI-!hhS1sS=mD zYWk5vcna|Tw3UtBNKL5t1BjCPK6Mm{Qv0*H_Egs8>(PSNW|TM+qK(?21g!rS4-fu| z8t4AhOhR<5mE5j>HnsR>0rq&yuODh{~FR5(()}z+7 zS|7%>IlzAZb+_28DiSbZ8LRq&fGtA7aZz;ZD4E)ocloWid0qdm%(6}--HJH z-C^}x-qW-H6A$q#n46~sQePuTYF+w6Quy0guE#cK6CCKB2w^z-S$%*9)iMW`XE14dGAfm`=%Q~`_AMMfU4N_|L2mi1a)y_JM2!5=Nxbu{kOAQZDi4E%Qm&Z|L|U;RX$=`8v5@n?o4>|0U@B@SvzTsgtA z@n-KCsskK6r@Y145G@bU=RK!(#0+F$j<&Q)xEJ3PE^U=vsA zJ(S5GcU0(l1=aOQ6*M*ktC90voe^B|@Jc?ER@v90-U$^$vckj34yI&cLS!f@_P3Qk zeGE39>*+=NfC>8vOVFt&4f_k?RkOy8JRT`0`Ui00w&Xm2L_wxWPLz_i;R*BXnq@Z& zSa^j8+C!%N1@D~MN6%XULK4r!uj6Ny6MB*gx2H`uAx$&-9ZbL@R>hgGX7k^h!qA_- zz4lsP?MpjV9R8!BjGbJl%}EHIpIGBH%=6~nI(~Ok2cDDr$a@lgK^X!?RQqy{=mTo^ z*Ltw&v1H#eBJ2d`8|FMu&|cf3zYS!OgO4%WS=pfn9K}k*ZSQ0hJR#2~=cexp1Ub^p zW=X*bYE!$V4EdA(aI(OcwT{+WbiyW=9}M9rF3G;i!xG@wp_tV7>0-p%{rr5zj_{_6z$(`pv}Emp`52FS$5RAf%ys9+BKM^K825)aMnQC5wxZ>5WLt zAoS!GwF5+=7M1`r9e3}}Tt!v_WS$oESl_%553lx;MsHtV_}J99>7oC;3+F5&IVORu z^urFXchZf*QxrNvo$!+-KzEA%6+*8>oRJRVlL{m6jRCfEo!CJM49{qF_5AY8KOmRp zy-sy(_6H+HkMs}EDzFdKD|}_;h=A_?rje1Nwc04Zn~7{CVbq$({T(OG zMC5A`oOV$4Ahxo=te1i~auG5d8@V=_#XZd{DpVeqKI7ZYDV-F^`=W15NW=-G7k8UP zhVk;AxoX-X!Nze;MLuEh02o^!0<0x9vLna9G+2jIX*X!?USYO(o@JcvrKi6n-0{GR zWC|jaF|!}I1p0rTxc<;Mt7El%C)|GX7Z5pEFD^b$i?o0Fl0m6Z+>5yd}s@H&vu_8qYH ze*afo|1>^oX1!U^dbxr(_}F9Uc=Y7ME=R;ZW+aBxGpk`=X{yNYLvb}}xVl(BF@&n3 z@tL!KzOpGd0{tvH3bjzQ-yEB|vuR@ISNsxDHw8cNVnG-QARR^~PIJtVYCLgIJ^?0> z?Bd1HEdWJAzTz9Y;!vFpm6@gpcScF|*x{=qAk?b~@ToNrpUg+&Z?`FBZ2`7{2QI(S z4hc~ExaZs?e(8)VO`9hRT5^h>s#vBlxweE`Am*n`Di#+6@G$Gh3SpRN1-syY(xGb*Yi+)@Z452RNheHpesl85y-^ypotyxE z{s`q$ZA8C7W4#g{Wpygw!w>gt4T|=z7D#FmsE6P0{j|fPCtx}v8=ZUBzUSOtK zc8wr%%-5A6`1sh6pA|Fo+%+=eZGDq%yzlTNWzSg%u)=ILSZ!A_fC}lr&NNl|{gcV{ z1ga%a>AeYH1`tj`N}QQ5`hbZCYLi-tB#+CgxwsN?r?CqjT{N9O45yI#ONy5-t(VBZ zhB$rVdqOe{VcERqeyA{7LF|CdW7y9+r|u{^AVfey_ovh^&!HbD`Fv;R$H~+ZmA4D7??^e&88jeS;z2HWwP2meVAbof8&}p0tgb%b6gu z$O@-FuLXx?D~{K&!^c26(cVXAjB`0ihz9rT?DVrbYH1Q#RjAi%?3v|{*e5VN)}q&{%rV-J7;Q$IU-YGp+4yDSYi>&Yi0pd_ z_xZ(K#e#QtOIB8Z3p@2HTc|{nT-V>9vQ@;LH5OYu3m`0}K(xyKwKZ9$RgQg~S(*-> z1uAZfu8Ur#IMqMCyy&^#a2kw?!ETYV+At4swWVOz!vtOazyh(4bq{sC+f%Ug$bD&Y z9oB49AY^8xde51$lcX;xrMh^jY-;{-rx^7KD>w-1j6Q4k^^{FFlCoO9%?el6K9PUf z8_@b6CImo#6_JbAcy-w@^!U4J^g$_+Yl&$mOnUa`g{rRU$Kt6EOB6@VZ8qPR(88W4 z-k8OBr;vZ5=-;l?DB2+r0GaA&keqsGK{*@uW*;HbAMj60$bQ^3#_kUjYHfAII*yxh z4=Y8;vL-BELZQkj!A%QSxtvMc30|A=r`{NMoR5IfC7dJ1;fyUNo7aw?9k=^u;IzFZ zYX^v8%Ca};K5j)IR@OSySP#Cmo`AkjNTdA_(gN4%i3(5r!MY)^`$dW?6`9nCuq>2B7Vq5o& zZDQS1^1Qaz7Xa4g&Mn$dF%k%P@L^aH3`pCj;*kyXt9wJ@aj`kGiUEqhme~`!Ze|?2 zKbURT3ZrJ?@?kl^CK7-Hh2T?P4C>arcq}vLk5C>2Gn1NcweuAWfz_YkQBZEYICg%9 zxW3K!mGI)?K;xq$wN+PNu8GY}Jb3n^a>CC%e2E@k^4#gS7Rg1|-MRE(R-#+R3~T=6 z-gZKsQ+nAfZdvU7A?Jot&yFX2$cFFT7y2d~zc6iFP$cuR8yWAj^fvnnU&aJoj|t1W zRlwTaTy2ShQ75fxg1HcpwoRW7veTg9Es4PtA)#AZnRkI3qd@b23P+M}qx}VYXbvJM z@z!@%0Men&EfaDzXR^Z}kbLO~<25MrMFKc)t^r5szkoILZLkvLSy|rYP6O2L>GGp8 zmnK$hX~)n5ngqzui1PUVNrh(M(4jE=ORB?E3$wVc3_Xwmy_%%|8l6W8l-b+zPJFf2 z!?p1SFETnHY3gcrlNpA-K`vQvZ!X2w0Erk@WH*`T zkAGb->*D_`q4C=?iT(Sh^oZ>4cDn5kUvsHA;xa_=^f+yMqJ@B&0<)&9j#oOI+B!yg z7R`H2WEyPq{tjpy=i@`|jCn-}70V`~Xfa^vS0lNhdp-5=(*CmqRh^^qL2^+A z0r~@X{lb8$6&v!b?zebjxcpN3{y@r-AZ-}tIgX+Zt+;v4+?<)V!e(VU{Tr`2&n$qQ<2!c@9~5zGs)asF@!lN+(J$2sGgehK!(k(kTcJ8h~U6{N?%&{3{L}$ z_(8lE*KMEa9yaD5=S?*_)#fB@e9mXy;1on=gByD~Y=5-JNOZxHHB?!n4k2FfDDDmO zgRZw@fgqm?xoBd#+ci+(H+`;8`*Gje?5N!%=o76=5WP^9@^P)Cx64zK{=qxvL3N9v zj|V{7?(V1J1(Z9RYXieeLGZA6%p?&1+7}XNt-8jK)jZ{$p#Guw2#Pe0KmTZZ-nnU6 zNkP5dSH-L<^08i}^kFDTzU>DA@T_6K1S1WZJ2v;MJDFmkI{~?9nspq23o( zzuEotIB8Hdc>n!Czyz${koh=~;TE5CHB@0N!;zqJy5C<(pi8IXdQTtG%=em4$C@Uv zPzGT*vh|5qeQ&r9-_8{ZH-jXon$5J5VGjf+1ZUszGK)c?S~Tj$$}U=!*YLHFeHPHvGWQRmSA=62KWIW&WQI za%*72^cLZCLay3}AQKN-WzESq%kf=(nETX`hF@HA1vbu`uj*p7l*D;bdeA|p{W$*J zrB5kh2?^2Evi%ZZuP;-5skh|tFxE&n-K@m#nwJh?3PFDg>Fp#{Iz7yI+JM0x3|Vy; zotM(;zL(~Pp7@G+1zV3HRI+ZDosGs+zOkgm#0U+B>sx9hXJ@Le&eCSBOu=en7ovr? zIZCDrC1`jAE3HV zQ8i7K);^Yn8g8zQaXThGecVt+7|C4^zOo<9^U$u*pxH%9*wGV!H&w9_+fZHYb_@!US2B7KxYkcAm|Yj37C0ZnttDXDih*VhR|H zfjcamfms9eO$+6IVz8%-@Dl2zA-XbEjjcXj%_6p%w!cfi2$J`Djl`a>FI8UB1l#+a z+>Vczl3112n-T0Lg;Il~=d~bBbQI66Q-~$dryN?ANze2uP+n+ea!-fzvcQHCNDOP{ z{CAb7o^+rp^4C%IIcNQ-JeYWRK|`iBw+fIR$e=TZ!AdG&tUD1pgvWb)J6BmPSHPgD z5s<9*zTnY8kUt;?E&7@}vPfmLc3lngN^Gq22v1~ECnboQUkG--iLxadm0e8)w9T9{ zw@UdulT=O%mW?mHkWz_iynH;==}4QXmj?TruVcu$zh3CYeHlu5@Y#$jK=zgJh@f$G zLvH|FW5FIlM?zc#@lN&O6nEKz==g)}sHKKN&)o%~%JZ2p=8+85|&s{Z! zi_9)A?bbWCZ>1G7DKsuAI~wcfeEF)%X(cwI0BTULLxewb9AR!Habx9wLwnwf>xBde zw(VtK%0a&me%K!8wLQ*b4Impdbb zy7UFwnN~iL&^%8M>e6A?sxj2Zbo@>siZMow>w}`7h#yV1HU+RjV?pq5nM8yZ-6~W` zuyjT&30~*zB_PB&i>;iZm!p^UPC&KYI2}ljGI1)<|T`^qN&8k~mPo?RF>$ zelSlz<3^W>w)qz%KbJD)#k|Vzn0-fN6|rxY1FjGzb#r!ie|a&_`yh}D-;E-ENSy0| z5z1BW^DZ=2Tp1j(yb4CaqZsil-Ncu#DwDR;)y^9?35_kg-M$UYj*HwneNPP|dnn(^ zZhYzIo|wk-ppaI0lTJo2b%N~;XPCUh?m;*^O*tEWij!S!u}sb|sow)~*pG(o`1XwLKQ_XE9vr1wb&o2G9) z(Z9*}^1+!*N?v#N9{%R`pXyYhQt2h9aFb=>&^*aKA~weo`!kdI$}q5O!)T`3 z407VVR-CU+ec&e7+PV%e^!VilHtjH2C^uNm2d(fisbI}pMdKXO3aET>HhAwfcvi#n zGuwcjR0Cgx7|jtl`N_4d&lEN*iM;^`R2Z(EtN&GYPq%wX_|>}}&97d0imcJ*lRREw zP4Z2YFxRHAAB*@nkr&Y%QPTC?AigD)(V|imU%qyqxm%v z_3fCMjAw-%4Wdg9foUDX36uK3aM2q@QkPotRq$z`oT5+7-{bA1yuW6}{Kz{t%j3TI zQ>~goX~g8=amh*LFyD%rcBh&`=CM%4T`)KUz+c&85aCRo(zYZ4>57m!2*ry*{1Y__ zck4%n2_4{HAFQM!{U`I6{d_5NkiStQ25F==9f70ma5P_?~PMUocbAfS(0fCZ z*!|ytk-0thllk7yM6(7@o~3t?!1I;t^RpAoCMtJtF1|A~qxvir$d{5^0?7>u+qE|; zE4t~aaqiC{C~CBD<}Le;=EfZtDI>CyQ^9NMe4rNecyooHjMyS z!c&JOZkIe8(+M!vK&nH^_q>D^2Ng*yVqfbH7G?#!zFdpzP5~^FxE|G2yPnT=uBmIN zk0Vt7>fK|_glR?_Y5ClmVYbTMd46dGzA;Wx=QNv0{5!;lledNQmz>E0wv1gY0>bXX zHcnHT#v17L_j6lgxi(blj-us1^mN*9U=fP&|1D{x^RJ|lH~&8yqbz5sn}{n06}l}B z^sz&{6?@+8r^Y3y30vkUg6b}@be1$R5uZfO-Kv1>bcoccle&{{JgI9VS+#5lilIrn zkktFDYPZ%8$Til+!`0*p`?L=V6kW{JcE9+h98d`46pbFg%J*Zhh_GlV1|?4R_?${) zH@)~gZX%{$G>G(v6h*L8XEYA~)JAzHit1>4Xio)}PMJ}^?1La?XJz-@W3AxA;wU48 zdG`v|V(@oa@F8hk*e&ma!S47S7eVjHiAE95`ercqoDH*wLI-fM{nS&QU1t-}EIVKO zKtMNVnw0Jb-IBZlcr`Gk;qfkH^G32uZyIk!#r~!W-{$8!oUYMcq~<$iWxLRkmtpF- z_ND{kVYMUQ`;H)0xhTEP&!Rqe_Lqp%?=sn`i#4JqD-fL`KXU*2M)THVwtF-Dn%Sb_u|B@n#y1JJ(9B+N8eOU~{ZoXm2Tw?lThD&)agY7*}fLuB#u^z(~ zZc0H;=MrUo=A8=)b{RD%Jtzh}&c~XN3wMRFb03N{uqsur9sJ7bL@u?eb^7`R@kw}| z#AqfwUn*ItkWBb;>Dx($m$7z<@q>6zAwYAwCoTRtBvtoelSnrdfs2+J18f7^Pj!gt4o^*NZ=z+1w z*WZ*PPfmx?iQI4DTLjB{(=q(PqX`-YG3_X2W&$T?MDkO+uS`(edXqNK&!PNH-|_y;cS6&EAIILsV>`Xe<%P z|CkSm_ThKrc8fR4+S~Bh)$>HH~JY)&`I!7sq(AoPMuHml8) zJ{Kd|w1(4CfcrjsT{^JQt~nZL_;5jZ)4^#8fREikh2;TaY;y8}PQ1+_^rO&AV#nUJ ziueiiO@U7bj~Z#0&@UoC;p+m}4}J!ENY6l7^IIj(21#&Ba*V=5V|~P2-WX16J5%83 zq5tdxsEx=wOK#mhlfG=ud$#g*kFVstBgQ3re@ys-WE5jfzWoX8Iq1y*_%o#&?OJv!yEBMd` zIpXx^UI%qP%q!X2jCvXcb!a3dQ^oE&*PXf__F{*%X%t+u_FNi-Sa0-%U0rTtQ&Jl7 z21|}MM?(cyC*W~1Rd4qn9Z`I$yJHtleGQYn#U5ktvR!BrY`=YQHaCZh7Ur{84Sx%C z;wIadM{%3y`wdhsDgru1jEnG}gph{osXqW3MR*=x`uCmZoEJ)<7(D0owY9&#C=eAk z@#We*)NGmBmZ8T3@$7(3cK6-=RnaGee^Z%y-*=tn=x1v_SYAr^)~o3OqRn6J`?1x^ zEd@&1Jt$kf_1FU#802tB#*iy@I30$_6TP$%ci?#&v3($gR1P}6Gw1pFN#e!%$cfM^ zAPJSo-Idi1-*UXoBbBiUNlyW#@tl{@Lh6pkFc0B63vKC!ly-e)Az zz^fY)_*hh5W0oyE#jYrsOUMzbhiPjBw#!OxV=Md$tc07F&}FBd79ZbIl+Vgqb9jBO z5-q0ALnp>kMJ~_*p!FSD4G^`J`uc3%6IVMijYKuqsN=b&hxDV3YDd4RV~7tK>Y&o@ z#_W&kW9Njk65y|k_Eg0~90cM^sRNc-7>Ru?!KLFp+eVPB*lc})tc=EQweXQa-dTdB z`C5u|HfJnrNH;{*^i0+>b>)aWB_GFU->C*q0tK1Dr=27l9L`}v{uMl`b_NBtA0Bnh zu0`9_Z$X_lgcGGiOn)!!2dkOSQhiI`wUt>pVK1(}RQ(PLN1tW} z3QeGbDqC1alHs;zl&6W=JkCnBhu3t%e3sv8Rh)FGHFNw$V}=7`T0uSh!t5Xpc{e8w z!6*J>jbixq5xh6&WvzrhZixu&ZK%0x9XV9GiISvUUDFK8@_2c0Ux0S!+TYrd2&Z;5 zBYY^>8Ap-iFm%aiIOh6V3}BN&f1QK0orx)0Q~^t(!0NPJdZ?8r5dHj>Ae~T5JIb?Y zwwukfCVciMqUQO3pphg88j-qbP83`VelBnVO`zuz5RK{`*$%V8DK5 zM?Mq8B;~q}CE)&a4fp|_-D1R|C?@vJFfpcd5$MJuCvsKACY#~bTc~!8?9pQa7|X>H z&qLXnSV2kTAo1uN{KA4%KKoI6clrC;y7tOLAZg*kS}KYfGt4~JAs=?*hibVx2+0g| zM0+RC?LaBswy<74UCijKAvvAa`;50S!rY`v6arEmuht=#VKQ<#|o#8B)Sd5e4=jS`Muo*i{DJpR`Q+fIpLW#U*z80jP z7T|v1&0d5>VDy!!?4yw=6L#{@oV#SW!~{u^vC)1` z=X(?5z#B!T-P{|s8-V3BpcC1D0$>oItO$Ah@DioN(90zYA_l}hA%9^_Ho6z!t zJa9~kG!S9pzU%$7WoU(>2KGk;9nh+!c74H!F=4^80yyc&x88SiOPx`E4H1CMbR?!UC>HAj>89Wp_O#Ka=iu%Dp`rD`KYFRD)nSZIjCtZh9kxI8CAi zW_kG=p~o6c6JX8wwoDA`^kauG!ia#7p&oAlQ9diqEmQyJ16*9f7;(l(J9O>vDy`-= zINIKxJ*cHYz6vVLHNPy4m1>&^&dZ&d(cydHMvL5=y%Gv0cDr^(^Ggz@ zL<1i*vAS3J(aT${W%|k`(Rz*?UgWmuP-s_f`@q>`!rQpV_3@(VytUv5gI_HRA|;PM zz|>16pw;MlzkkNJ-k=DITjV#key;;mG4-z_YJC07RnVIjr;;LcFO<1^*4q^)OQ*C> z2wO99p=NO1+?{AxjU)mWa9v^Y#3 z%#3q9By;eI1$;?8CS|6k6uzqji56LzKKl8{(96l*H{$BPzUzIb{h1kG_*WawIy9a= zbZ0Xgk>NL729`S&OgU0ipC&zYN9Z|cAlyafsN##-Pp@j62Il)OW`-$mF_v-`b1Ms~ ze2+*v>5ff~HxzFjVK43ReTEs3T>U^|(YIWZi80IcWXqk69;ju~iP(JDB-a#Y?;dg)v3XwReBE<{4( z$-k^9$FTU@YSa~iOKDz_>bqc(bW-9mU`UjyZi{3_XFPuCh=)$;HQ7J1X!U(!;+T@# zGwH6*wd|w*o4xB7XF4G6Vc#2hmMlo-02Ex-8y(8Mm*_A%%(;3Bzu1Ni#xKXxxRQuC zoX)qVTAy(+Px!g2Vz*Mh9u3D`7v}O$Bi4azr=~@pn=k?|r4r%PKL44#hvJ^AAz_kG zoFpH{dGWP^FUyE4dQFdBvE)a;5&pP~5SZV$B<3O={thpY|AB6Qxitw#Id#-(`QDjg zZH_21;fSKwgqvY5#}2Ak-KIF2A5RTzSmVrY@R)=Tnu7M15|jUmqRc|u@PH6%$-FoF zIWF!1>$n1Zn)&<~6!slHd}h3f6C;{`3gw#jE$e*=`FDzlz2NIKrMO!qGL+G3Y(Zb* z;Yn+V2(R{v!^Z zGIJQ042AaxJ3R~z8JXxav3$=wS_Wo@Yi3Kdjy{(`IBv>mWfrJXE!$J2NW4kb3=}+`$_n2{nLsZt~r^9W>yi$JAkSJigJix8m z5FgsHQ4V(xoBp}^ePP1P}V{iF%F+5>$z z{BiIFVB9>0WyLII#-0C;Js)~U3)J)Z>Wt!e8Ab#ACd(duNk%9J*STO(ljEH#sG}P6 zYoe8NE3;nUK-# z*|?GfK2CiI0AK69g$Lki782TF3G-&1#qPbLK@_LvPzo?K?&I3kz4k!eM>7(tjp>L4 zlX4*HFyV@{?sr)VnGXU^^z^dHm3p+N2$~>XjX~b|;VN7)R!F*!QhkaCMqt@h)tjKj zt+I_=2@qWoc#!7BS=BS|OlbM=ZLj?k-$K2pssvj{)-eRWAbj;;_naq*@?n{mYQUgD zg!XS02#y=0qcEDNS8bVR!Mxh$l>l%lo!o!wVH3}#a=5dQOz3G5p&`n|=TU_ss$O)_}ac^9iSo-q=XlrrIoFjZiuGpcjpJ z-DK@(sH(?5rTHgjV$q`iZV!Pt?IF3p?IE4A-0Et(d+Qe09YCD+kj#JDL!M+Yu;ka8 zlmFBXUW)pZd1j5L!JWWd1$g0)Vs~#K9x$c`e!-I<_y1m7`(5yM6G@@3Xsrs2_FVn= zetOB?`vF%J6U5kiM`)t+JjgKv$crkdL2ka6>)w$4xL?(;96;)JjYkTe-{bJ1gIPhe zqoNlpk-BwIQjh&pW#E*Z*XC%^Oqja%pZd@q+pGU^YkS&Q?T3wF?pxeV72)azXEc-# zCURK~Wc;f>^cEq%rN;U~B(atZ8#xR1YgqkX{0e$RsGQ}!JAvRDI5~+ciYfvWluc?0 z`L7;Y5c$9Kko}+f&{lZMBb1w!8#*-F<`r~lwb^K41%G>)lOL)CRWGtHX4VS^7sTlQ zKh=lm96i*r*}1S~Btm3-`CA_X{=d|R)c&U)N~mdS{I4ETd&|*79^s!IZo4qEkS>qE89 z3!KH7f9gY+m`_wV)yoz7gkCEcQP@ct-axL;+j**~VlTPKtTijUlaNCPg?bw%1!I$r z?ru-K?9l9}bfe)3-#bNWgN96ke`~<2D2-YDOF-*{^CAktxsUHkf;7-)#@q5Xk0hQ! zEev8QJRfX@kAA6NS`mSOse$14fK3-UpEcqbmX*4<65dc%os}FMHLev)_1^N}T#a_c z_jh@u>21%V7QBW|m0BApa@K}K$v1s{qvhlPEGn?~nHN@>)eN+w(h(^d(F-bV#Er&X zB*Eyh+oHQQyKo~27kBqhOqp~a}@86#DK|WY|suz z4=H-pf)9_K_~y6XnKqg{Rhz{G+XR9}PvC>(!Th&UGc+}k^rdrak^7g{yK&*?)mXI> z58TnA*E<;pP1n3>zJKr5I^TxL1ORF)n}(ZALMR1|8Vfo319c`WGb`S1>-#egklm&PsPaaaK@IVhnOIiT`jVef}u7WBXhMWh$jIMDG#eP}!8X@E#y zk6uEqL$0Y`-?-)FQQYXn%-0h{W8toqhq-pq*PzOWwnto#L_Tc@W*SAa~xMd$GqhO1Y0QyiAM{_pM(%4ehA(x)UQcX@rkB;!JDLIpQ}agb!l z^tcq5Tz)y?3bF*hYzk2FoMMlEl0k>cZ^kBeI3KHT2NaAceFQednBGt+DsFy+5wM zkTH@c&wXFVb$;xxUW!&2@J=73xq4%k?Oq9 zt;9{?e%C2{#2+zuL~Zw+llbTs%$1-TfsO8)h|s!hZ1nEAeE=MG`HEH!U;GRtL(&=c zzOPVE)nRf~H4+tGWj_b6;!x%0fa3AwkI(M;W!uE;UOSf? z-aE`^5&tyg9piHcGm~f~N~Z7$hAb%|={aW$7Q^0>(*oy>f_ru5l<@&M@KIZ+;WRxO2$P*{0pi&$-ejXNpaX)ae(LEtij{xK7;{42@0|EPDmjY|7qt zrBeqR8RvXyU}KLvCb43ptBx$7(bxoQIrbc9j%Z(ae9wYEOX1uHBn0v#`{dXbiVeN;W~lII^CnFg$}dvlnPoQ2%wIOiXv#n8@+zQ(fZ3 z*EVLse@z?&$-;Xrn`a6kS11>fhE_Dm55tyJ6^g9bc?@Bu*JUjsGd%FR|7xP)L6!0> zq{TBrS|RrWyz6{r8IpMZDc?wiv}e~$6qGk`l7C^n(n|R`G{Z9NUWhexvtlx^^|>Y{ z;Hotw_0v1Y6^5!|JWR{SN_Kg=e&D+x*Qrlw(5bMbUzC||*<7!KMzR6ya|6hoUY%lG zH*jgu;<_L`3q;R5U1li;|5j^CC3bY38jp_Y)^V}=J};4p0c`oBqe1X6xAJk=npQ1a z0i6jx6+QujOS5{#*o~pw0<1^bXZln#-atY)p!8XFIL0Z^`pAo7R&MRH$U$v!FU@(1 z2i#<7bD&+{P%$E~l4x6pCpjVemUwC2Z$m4sei~{9yY$y<(I?oEA%z<(vu-O_uhwOb zphsWc{*ryk`eo#!p8jV;Qv*kkM^stUzKAuu8m=jiuA2N>ciaQEA`C)^&x>+`?pRtz zDD8slQqto~nRA!sZ4KC+(VsHuvcCgG`n0o4nbAaHI5JI?YYgq`sK+*EBuNk zGjr#zcA8M2Q~U>fYjod-eY#wF$bjo%WI|X&VTA$g+Mv40`#qAs_m!uT8iA1CSaGXv z4!E^xUsfWGq3b)jp#@P%a&xr|j4;3tVPgvYXvme*Iek0 zG=H#>pT~luC;|S;LH=U^8ZNMY6WD}G7W!O$sbkN~wVCnEl?$Q}V6fV0+k(XcDEYN! zz7p`Y!tr6a!&(`B)s@3UIpYzI85X2g+o>&oqh5OVRa8>Ms$|x~7Zt=yXX0R!PXf>~ zHQwR=djC2@C4W0Z9=4+TiVz2cRy3f(=*gQls+Fd3nv za)c`!9G~&aezvAQX2*Mk+(opbABh3_-1S5v%r+ClQi*$Zad~51tWoIF@JDz%Hn-#s zKmW5n!?agq9uG~Mz5(q6o;x5`{w-I}x!{y7$DN;qD;Yw-fT6dx*Bom=seP7E(h-lE zJ~;fy_FpVS8evT97ZEp}^1k({ObGd*s>-Ar`Zo@EoIim*2H;8C4#toQgTxMTv361> zNIAO0t~`HK&T!;8?(N=3Z5y&ABL1a?qA=laRaarI<5NlEjgW^9)9eVwWLt4lfA>~M zd*U0zd-bgT>eU~Eb3d_7il{5z-m}C1%0j!wihhb&ZjgUoh!?dgnP|v}d-1+-t9x}3 zbcmFsRru`sWc2pXy`~?o+Yc5hwg~&5p3(AHY%x^y;o;lwx9|Xz&*hN$4p0rjKKxEM z>7|_y@t$q}%uzr#xjwP$59gPFG35}Un^VYOJgPy211V&$Pm zMcVZuEezPF%Q@y#7jYj*@nqS8{*oc>|BVb~{UbvKQ5S#jdS%aO<%l@rd%MFYN zikUPF?t{~OKt-_Re zMgsxgT~AOegv-j%9E-f^@WvZeN&dZ86g@mJ*;_}Z)A>TlRXTQP~zmidjqsP-4;qaO_gO8Y&Yac_05ZMHGuG?294P1coS36Rvy3IWaj3m6(fH%zt6Ds-k&MK93xk+)i-MIe;X{2GW?huS z9OyW+3z^e`W*url=Aev-mKs7P+;S4CNeeG>hoac4d8q~%Ey`VJrf;vf z(_AXE@HuRgJ@|9E&lUCJeK`N;XAoTR0?ich$eS)wBwaWRg1p`%;)*qnzO)xxYi;PG z$*3Nc(!h3dLlAXRQ%%VT#4LSOl%uTgt?}I%`!6r#c~QCVHR&op z_Q@Bou4%~BIJtM&MI^gvJK60s1Lb;BgHi>Qs#gWv)ap2PknrI`#y)wtfk%k6;_5!T z$~nQ^q{Rk905Ua7oQM+_dvBtW0W>cKRTvu-ODAV*FEFGu zSJ3}mte(<2iLv~8a7^J0B3D`o?(?18)wUl2xg&14KkRlkEWrK7pb zi1Jm6el{iW1cXMs*Dj`rc!2|*xBMNm)u6CCRV1q&G0tiFqyOQYoxILQ8cg2gib@GZ zfWWzNGvVjz67pw-{@%SNh@bq7T7SZw0Al%@6SCS6KOBunN@S4)J4=M`HXa1#oaY?k zCL5;ZC-M8Ki&-%7=NE(J^G-8sVU(q?=h{F0n;O{@ZR~kH<5KLvdW=nL))l21#di)# zOSErs?v8lF1T9@g<$Hg@nT2H4H6wD3Eo;><4^gvs!k0EjU!4&%^02*rzzROTe|aJS zU=+(*{#zEB6iV4R1>KJEf0_N{H-!4QgZbX3{@|GEYEM9tr}rmCz(}v`^Oby7kM7;pQ)Wy-bZ}(5sc8sCc@m8y=kk41NAuI)|%c_`|o-0%5 zJCFO_K$9*0Bl`|Z%7%lw~qRUD>l?mFg} zAMU$3d#={H@oe4&#U_hk4aQX7%c)~0WF6l5*F!cAsHdngC54%-0iHISOC{~Kg{Lf& z%3xFN7_8NF^eXeY5q?hdjmb2<)l^w>6xU{biDp-04QL$O^1eJk8W-zIu~W7pJ@f3k zWoyZ+j(^-p=Fyl7KMtiCpxmxIt+Z%GKZ`p!gVq+AzWPg~cw7~j{W|_aABtaI`&fS2 z?8~EY>6b=*{>LINWYj#l`M?1i)~;UK>CJ-xrZpmNUj`2exSe3x<;zAz&Ki;eI}ZYx&)FDvx*pxUPE z9L!D)b$$cryX&U=_>=Tng-eH6rS!cpTRIC z5cya|XD^@5KPqT|w+gFVnWh@64;&OmZL>FUKe_lbVy3IHlq+y$qE>GQqL5#2XsrX( z-?mC%!0_9Az(4zeNLXk=S)m{Rvf06JJN3p|^Zj1zT^Pehz@E0CX|+QkOT`n-jsZO1 zCPBx>f_(IrIX#fLfn-jHhYelLk{c2`En^cp)RH*ILzd9ThI02O^D2K|N7vYFOFqi? z2VeU0D-aIz^tzX-eRoO?s8fYajgFz6Y|~S*+eie zjbD2+*zg{*UmLAx^>8C6f5wgBMAt9itZ6-87 z^S~{JjuTtK*9C#iS7$g}Lqjl$lrym}&;zfppcU~VMyJsJxe(W8o|SSvQH(N%d8Zq) zQ?jW>6J`0a&)B3nSbiEKLHN4InAm)Ep(}2($d6-8AB>c$zZ_zW)~>2{qrShl&Ke}X zQywIzcC&?c!f7Rp+y42T&<@~(1jk|P4H6o}Yoep!3Y)kvadg->m_%Bhz&EcAs2ZK6 z<@j9(K;Qy4z{U+j^nPO0>efKU*h#2Xj3Se!4_3D?l|ea7dAx6zy|(Km4N4-;{mNoZ zi}&tE2aw7G7>bhVt^2&*!{4#(A#$i^@Z;gTPfO{SH|`zbt&Rp{$M&XSnTgy77w^r= zRtbg%{pe4DF?bB(nU@sTVd$;Z+|2uEuDOb5Cze;kktyw>jw2y;B;#9sSIGj^ZmkJFS&n|vyDJ=M9? zZK)60q^^7A?R<84ZK{JQM|>l4G5FUi*9#jicaxumhcD*oS)q{Y!sm5B{wYw=fa(1? z0G#cMVJ)pd$TJK&erA*4tYw;&toh|uN%#8K#h~jiMl_z7?C1=`_vl%Z;8?%8EHdo1 z^b^+FtWb(}Jr6c9G&iSaszS=~>r+zNyTPw?Q}WKS?0fg!LE7ipgB@`HkV`qIy@XcM z!2W{b>&T|r@V!HSAoRh$K50yoLd3I^<%nY@_sFMys611BZ-?s-%iQSSe{7rDKhsyd-@HX% zI5Gp!kiaCQ4NfIpI{^UV7r5f|F*91DcWuJJFB@!SasC&FJA6!!%%T!_3( z^C7qePvv`+&9x=3t$DkmwuOM9Fg z5rhWpRkkO}bnctQT~ttK;?-(ia#$!=DkQe%1GPV9M0pzVWB+%WD;~aGd3Y zE|`Q><6j z=g~%=wUPSc2Zc7@M)CccKnV4T1D&DWng}P9uKoj1^=oYcYVrX6$N9A@ndQ~4>dq!2 zp~@9GJU6@Btv7<@W}THvjNZGJHxpzV7&Ryhu7@^NWZC{FJvip{#Xpm_N}gmKapw^v zQ|_UCQTCz4is4e{{Q!*ey~JS9L<7_qj2%sIY*O?Y`vuwgcATjV)nuPBaNwqk8r3bo z5sQYm2`keW9b)$@&pB(3b}fk9GP@%LZC0Cp-kV$)Qqu)jiW)@?=r3NRayZ{JXs1K2 zCP%=Z0u^3p(?3Rp13F*z)=JG5DaaqHPv=LF&jvj%9AAk(Z&{OfiT+x``fTkh6c4C5 z)o|S2U;86Y;yDNsOIvt(KSZjuu?a%J*T~B~GuYbMUjN$?@}B63Y5KiO#zTlHw_Ta> z^!a0tQGNbP`h7e<#V`1{M6Sb~tw>gXrfCpLc9=feCXU+BQ$ zDt+(0&!yOlTj!5NM0mQlm`w#CYFSPafCED@dtHmFhFfIy_p1n&wEwM>P=Zm8{$Qxe zOx^740{S#o)Mz+F@dfru3L^)r zxDSNZ#*m(I1V~!HMoiYZUs4!5+_;QoEP~Iry(b~CFG9%xntdzYLJgo zJ-G>c3$MLZuQ8*tnV);YLN{52rNNQxSg*@ry|g^dHuif?_JNko{*S{s-?*5je``YE z+T8-Nyy)oTTD%XYaxEIad;XEYd~+siM`Q0JfZ(fEC|21(2MzU8Mq{T~26QA%=# z^OPHvM<7JpxIM$r^Sxv%h<(R|gbkwqW9XoAAViZ>LmW>_6Z(?dmVCYC%z?q#m&X(C zAwV`O*mghp{iBe>r--t%%k7m`z}q|J%Gp;bMztAAPInUh@Wi0;(pn?r+Ry%4)iFAHkxhTW=d zZ(?#!PImsGR7btVfCHxdxka{85T&!NZ(!9Bsga1OjNog{4Cf!GCUQSNJCnEWLRJHU z?c8RVnz}!3Zq-cl*vo3C<~^aVZ=pqhE; ziyZJ-el3N1{|Q$xF$4{WRwsNO^g~3Mbmqpaq>`}R%jtRCto=D%J`^Ks%JL@-P>Akq`%u5FE)7KV{tXG4 zB^-#$?mUw+bQtvAOmmxWVa7KjcQur{*yzbF2{9w;Ab;skeX?{eOq^uJxfAoddh>p| z?#<0z>4J1cQP48Z63&eb#pURu>2|KKgDVX-cfL$04-nfPZhPfUF#GiPy(R?A_ign! zO0v}pu+XHtbIs%d2o2WbP_=+9>j%z04JD&MIU*09F9Zrxn*qHpi#QN04<*)oA z)9Z^jHDT5InI1|WBE9BmSQ4%t?BEVnx+(mk@!8vMA;G8|*c!z!VagNY;{l$E9k!@W zIF6Y_@RSys$ps=os*JdHh4zJ)Td*d{6ASvsVDu>@u_L(%29AC(z>JmV>Q-{S7| zxbyZP@OiOMFSa@2E8LNgF&Pys z_np(CX9gix!xSNNwdRj$!_Hw>9+r0UfSccc4VJ65J0}nSQKJ9qM~w*OWONJBtE|50 zafk}#WP7l;e+c2rPNJBx9W2n~4_@KDIZFEIa{y>MG+o|Pqx@e#sHQKFJ$3wmLqIT& zelh~{`%m-R6AyT;#U81%aMxla8joKZP`Ct93r<|^{(2UKhI3mW5@>|(?%72cUHC3$ z$rf@delXWJNLp^dm0;Gc9_%rrDyc)Mo@K+H)&)hY(u>cB{`P~^wsXZMcjdZiZmQiC za*7(}0OGr-7p#s?O24dOFCUYFAP&S~Zu!>tbCv>co+OZ29VD2nOo08jb`O9d$`XGh zn(kkcy*_sEdObqs?&Q@w?D&c_=4bka-V*R3R0|Md$K?LSjNsQ~+)H48&zlzyUojJT zu9vX%$ozcgx0LV?3+#N5EJ_h_29#jv>fa!A;Q<(u?#%!LC-d$McVUp-7iu!O25}A{1~oQ zo}v-hI(R(QA8Jv2v2rtY(*M4OZG34W9DJ9Rqf3&XbE{yTj)te*|x|fu@ z_lrL<66HB6YuN=Mr9S}_{LKwQ4Au1Vd+y2x?~wQ?`%CGW%{ zZ#YwC-w70nH!H#t(f=8L^SkP4AhfuXjJKZ~4`ynX$g8q4E%LLM~rx$bF3MfWvd&_N1q-C+bxvbD# zg1g7A#AQgBYqR22kNZ7#xBq+`|paWLKPqE#m>4 z!M#i%P1G~)_&GcIqa3N$2ISR{BSIHxky*_5o>qkhkLVKpp7PEbwFu6Y>OQ8`_fc^Rm6*8{_O3k|4JW#nGjT;LjQkJwOIx2l z;ao3~&inIkqT9p_hY;8sPqfpC$5>8LISid?TR-+H=~kWd*0ga)s&h_=C8_=7(lR$6 z&@xEm@^8Heh)XmP7yrk;h6)O#$-@!GVG?u5n@RHUd^FslYwqmSNTVaTjiq%p=x)YW zg*5puV1Ra3o?+*s0P6BUAXQ2U#P#b=1N*bTfX(c&HAR)LoJR&MEi?tbtz&J7%E;P;TS4aU0vNZWL(OIouNnJp2r1B{c1Us7& z@V6gypYZbI<^os{)4$T?P4#rM5CEcKYFoJjO=62whg|W+A6~QBV(|YqJ!dsNGRF%e z)rB+CCJ5kerRH-IUM9!jin%5?fueo6+BNaPW9AG)Rm%K{htOchD4scD7sPElP%n#a z4%`}U>nZ-e;toSK`^?cQ)QQHpB&$H{b_ydU8=UzR#NaK_RLPv zW?ggVD5*|!7!_Xa_u8V@|F||bF}cCaad06;74`2K13tq#LAd}v_6&oIwf)`R`(AzPst^P4?c9=_1V&$)P1|~A>FVpt`>bdPFrjfe_26&(E8b91CPRum3ACOJCxYgVM zS%7&@<~?GCDPdt6^oj6eE?ALQs&rR3Hvrqp>@6J&Wd8->ZJ*L;`=YTma58YbU{07$ z!1J?~e-^$ECnjranz63`fL$#`6Fp~zBihHh+8^XJH3WO3=~RhsN19FWw;h0}PgeV* z9sBaCp-gf(AH)7rH+!0O`%DYf^21|@w!3_mdftmf?w}mOXhT=m9CiPQ6%rr1vej6L zdF2qqrLHBqFl(%;d%kVHe|K_!m!DxHqc3gWeyj&u9aPO-{aGV<0@B= zr>I8$3_mS|As0=Uw>>Xl8zlvD1>{nU;iK>pOMT+jqTQMX-);N8K5@qK@Gk>}O>P@$ znanD%ux&LIn?YE?NSM?FU~Yq150HA1$1Jt9Hlz0f-sS~Rn>eQ|G|zh4uN>o(Os1OP zf=Uo4mlkad+6+({8z~|`hDQyxdx)pe*?}9V6b#MLGtF8tGZF&f5#{;)Zgmp0@BD%6 z`mOOtx#PNbYOn9y&$|2HGzG)Y`x%zb^0VGy08Ihmue-0mjGN6}+asu7Qo6raQm`47 zv8EYgPRVJK2)>X`lEdA9_?a0!x+Rf0F79gcnPPcXw*c}g=CAGf*=x1VUasDr6rbwj zqQ^fzt41GfZIjh8ssPlx@hRBH|B%Ds7+=e31kC+egpg);fot=P_Wp^clv6zoJ=y*r zDMOoq%4hmN2lfXT@U@N&AANnnUN__9biRdnx$VfB*89Y9P05X!j6l}93e!9fD=76D zuH3ta)8zhIDb^Zebk#c+2JbE{Y3+_S>C=?PjRhN)@Bc^m>=Yfq4Zmpkegq|zM{aS| zHdozQkcLGXD55pg+M4|j(9H}Hi(V+2QKQv-jA7|!AaL~;nvc2qKd(NjecWj5fYinc zIp@+`l+;YT?3-gW?Nm)6a}L-!j#U0b8?8S`=k=`#BJ%LT5yEZN0z!hn_y`_m^UHLe z8Fv4-b>y`&*7X++GzA<UVLEpr^Uf3>wETAP}*%nwbwIyfwE!WYGhyPlp7Kk zqOa7>!&@~r#$ne3<%^mJBz$x!!07sqt7CA~xK_}M-&|dj<-Q7fzzMN&;oh1Mg}=N< zbepG4*f*csb>G7Z$<{raf*!ck43SmiYF2nRpnhBTMHK|Ac*w#KaI6c(`zOfcI=qvXIxvyPIsC5{6wIy#m9lF=62~Iv>FO$Bac3f5 zsUNQbw1;GV4#OXeQ?Z*wKLc5b4gB{m0HDj8TNgi>h7-w4GW-rT>s0V9=7Rm)CfN0R zZ0OacpmpCH?Dyq}Ku>G3pYfmGI;y|_jm@w13lk6KUvF#z$Yd=GxH0dYX*+6el>rUW z-8AR~`_MEKlKM7~(JVSL{im%)Bb!Pn7q~Qn+c#ZVkmqOKtv0kF5O5K(5;J%<^|=ga z9r1+ukhKop=K9~LkDNhoG@IY?AbH>7mLi%SCj&6!n)MY{1Gvv|fR~hL_jge@%^iwM z*_+D9vz&fTsirY@yJy-5QB5&PR<%1t9b*0DRl$o@WYrR*o#3XIJUhi+49*;yjwE?l z(#v)E_d(&dqX1!77y8&l>4(@tsu?R-R?0*L&(D7<5>66YstM8n0|3ElR(2~p$f)o2 zK|41qk&oJ0L)KV)bD22K<9#PB33S~S1zqg1Z;xv}@RWKX~6C=Ppu zlE|GglX=d<&st9`wcCKkCclg<`L~$_T!$@qO5?}?4ya}dR*dY99I3N6$HLy@d1CjQ zf}4A2r$qc*txYaw_N=TVcDlFo*ap;&g=Z{?v~pZ9a7~BU$?A@EkUwCT!TZCZn#EykZzE(XDxHx4*)p9VI1GOHbITn7&%dOBUOtkivm9AHF za3C+mU0?Q5*afLaSpgwIx&8-@U4M}-R4_!b(cC7AChNiMak^IF{Tk4bHhY~|Nt(o| z-e>^l(@i9nze8z?HFB@jACEU_CV!_)oI){QsA5-+)mvO7HtA+d(CBly#y@_=ADN7k>N$l}99!xfi)(Cb3gbB!lQ*Yk0Z<%2S{YSIZ~j`E6GFi!DI#K`u~UJ)>1mhC z(kD~VrQ*)5pX8MVGWIt8+Z=?=X^bbs6AkM(CVa-Eh1>bZwbjn}U`pbiInvuJC7cw>Dg%Xth3b@CY>fEO|_)&_UO& zNpds$3FK+rX6OA(AzE8HC9?zAO&Y_u2@*+!jxhdv4M`?N=~AuugouCz=Y z)y{(dw9K5zyuJxIc;t>v`jIZ_ab*d&+rt!8OvlNKe7TfWb~R|Llzy*ay`AS#ss7ez zMNxIC$!lp1`}F()_q`F+{?H7uY->X@8bzv07#j;$u5{#%p~~jZXHV?AZ^EtI@$I46m@%}w(^>YiTR18+*pY;jo9f}67 z;c4+rj(I^L9w_y^k>5X`2_lJ$3(f`E!&;PQC=9#Zd1NJ+MZ}7p(`shVgh}EXp+wFJ z5VvwcPG!JTTf9SF&x9amf$2GwuJPKal-NlVPWreR2~i0c=Sq`1y6P(7gtii=gc#}79G z+vX_^6pJ*Tm>ZrDY%xBaDf+^e>Ij6a^b_<%BZlQIXmb20?~xh56`CfifXKA#ZaPyg z&G6XT0nk*Bg;3}R@H1keg|H3ITqWWBpVv&*k8J7xbw0C%1tWGb+@j`kBbk4u^(Z1h zQDjrG36|;q2EE5$T1Z)~^oLw2@RJ4UV6G^2M!XYZAGW~bQwK{14fyT5<(-+sOhOYg_2Q6^)R$ecmIibm-!v&9;s{|D>e;!l=_{Kki=&RTAUrvj1_yxwn&6BQ4 zd>oUvQ)nCneLMj-h}QFH?*C8?>RB;Dzy`|3TL~yZ-E72SL|j_PC)MP>=Y#m!+w}g8 z=fEPdZ>uVMJCbnVK!Y?>o$bUfb;Vs-SaLojCpR(DnqX)>o<5uFsP~1U^>cF_dMiC` zQWgS@Tbs@veD`0>Xa8`OmO6BPve>_ALe??-C&P>e>2LP-*;1Xz+xBJrp8cnMw1fFq ziqmT_9hLBO%=Ls$jCAn5fm{^uypvNrpO}5fm{_3S30UzfKe^O_3?0JNyZ!#1ZGOc0 zYJ+z>hl8>v$4(<)UvaA94jxy|Gi5?{;hbXlYIN95dH&foh=aj997OL-mn8>Jn7rh*^ zYTyv!B2O(6z2h#WSzewsw)R`Zno6`1d!R=QgteJPi!k?!A4kx{#aczDk$6s{h>q9% zp@WWuB}DZJ#YYKFifl2V)P{%ZgC6|Ol7VWZa;vQF+GY~QmmD~$ zVgpl83)ssT1YKbS0QxERGsio?3Rl?ZrzP#bTR}Py#;1?!oAdK_Q9t7$9hj01F%-1X zU}yFCy2O{xj6k!> zjB81(g%*wPTMuI|7?JnRe(Hn5{?u9D9zJvVP_*A5S)2N`4!vB2vcL z@ceNzEvXL=_;2_#Oj6V-xb+|RbLaB^;eKkv$F7>Uf~$8JMV|SV>rEB!aP_c-+tTI$ zY|dKpz+7w7Z_3M&XcjvPh16<-)v%qM)(ii$+pOL7hPc00PtWgK}*7nq+MJ``ZeqsQN$;J*IT?L=^6iWL7(v`QC;{$yp z^>k6`nP!PZZzSxSTrls@XP0S5SI2S9blh}D_P1H)NIyvo(ro%?l48{0aqf7uSAxVY zgBf4ddv3pZ{iN2B0LN|aw&^sP>ObeO^S}Vu{RY(8sg7wl(B8B828Lk>3$`i&8?N>J;yav~Sn{B(AjztO;4FDF zQoI}+%&l&KL)D4q_yKLprV9rfqOc)&W?R1)Qyp28t24-+?4_og=<2^d)0Q|!gZxh` zDEN4?ueie!<_NB+RJ~f5_s($4>BEoy(H~iMD+t7H1;Jy-mV&MS z(+V2oEE!bS?ZBl73HPeNccsy9E|$w0Yo~X!t{djw-m{?neIBtk$G5Brxg4r7qLYC_ zW#ln6IZ3MJ6BqXVC_N*K?@uTK1-MrmB&RC8qy(^{#g~WggCN54Z-4{pS{}XMk>k^? z$%u^WdO~Ce^=4-W7Z1y?u-*xs!*vkx&~*XMLaCgC!&$nu5-?1hs(FyFHe~GUtl9 z6nF=kaf@G)HdAJ%Ub7+)#nn|mXt3>+nXb`?T{S;m^2h@KlcW`NwxtMY`xVduVpkyn zx&MpdSfE@RLMm34Afwa@J34Le+C$)quF`#)kfXig7Su7f<5!nQ{f_mo14t=5E>StBEOpQ|K@*yw727l+A)~Q$=#WHwZkwtf5jX* zLeihqb&JuIy4!kn|G)STh&Gm0U(IktM8WFC6F*EKa0sYT~A_yNOQ^#Xm^>-;LxUHJ)hsT=zVPW8!tU~D>g~6 zG{r8i^J{^`u+&#QcNdUS=B~|Vw8|r;-zu3S`=6oLTb100U8;+uOk9iZhf7afHR1C( z$gSmui|p}+uTd<=>$=3qL?Cub)cAJbDvW_uXD`TB?o#f~CNr31yGj$(wY_6AmRyf@ z>r9|3p)Wr%)?T`nOTF<}96F{-|DLKiR`M0s)9bk}Sg@&9@ZK&<{&nm69m=#SO=)&t zQcvvY)mGdyqy?246(GYoHIX?ABXr5V_p6A|h`uihf!8V1l9KKeC~v@BqE zW(3%$TK7Iq&5(oV64HX_yv(k6EdPE|xtms1h~2k=rn~a}oKDdDz_!3zfqSJ3V(98m zIs&Ouq7{ZA3kSdb@%S4FjwYgq!`dp5;=OZkipl^!lZhj{^^ep0|8-n2$w%fZ8we!;t6N z?+V^f$ac2U3*3R!Jj`kBHD%6)S~cEtgvR)gtg6WKpFV(zD<=g(y=CUJ?8|cT?6^5l znBZ@Za{TDI9KKK9DII&n&k9$Jy-5ccorw;s1Bn1Drwn|3Bb6WzeLV@IJeQ6>n7ySG zhF&%SyVdq_XSrMZ!n~H*m8-NHqf2MbNO_=8m~jio#}k440+IRy?rk;SMtmif-frWQ z$|bJwsEZp8<}^@NOusrfKFobLI+qXdSW*FL8FwHjb}TPlQt|dtFo&AL)$F8rns~u2FM8#3tck(MqKuo-L5Ny5`iwJJUoKmFuPrpG$7`t4Vt<%Y@;Nd{;d#2)*0o ze2>=i8}cZA*gxOs=n%7%OAd+OMi1>W7fcgoKAXRm;Jr^{)-(_g=XG2#^}4%l;{s1Q z$TPxhWZeSdE@9@R`v>6PJFz82S08;W7+kz}pw6MF^2yoqP6I3N9hD&n9R9(!!R+l; zidB%Fddjo1bM-a>x`KpY!(A!1xat zmHj38yWciOR`aU77OwZnwULZe)sMD5)eVkBBPU+^n8AqeE~Ii#Byi;r<~hEFZZzDt zAc=|sj`G1?pYcXNz$mb+Ky;kW#hpe+apXqr85ggJNS9dzOsMp6gyNE?Y0z@E-y1`d z_{A$viIh{t`*(2}XH_9|gCXZ6&`a{$uXn)-xnYlaSuYh}G~-6^<5FS$>SHMj#s(&v zwMfYCo76GoK+c8J6Nad}b%YNmv2&ou=QnK+NxTuiJNWiX>$Z)KBEO54!l}t!q2?8i z!dKeq>zolyor4<8ZF}s3VIwsm5;K<+?H%?y9Cvzmmakd({9Xz43G86q*g9+zX)J3^ zFut`e4+PF+27lh)mUa>8%w6L0ou{gD03S!c#Vn>~Y(^Ohi2zi~uvyUbo+r%`gjc^S zEu8Y@p!X>^Xj~G2sDj)02IGOs1Z?9g$)c+~(T9-|si#?Zs=d$Hyg?2EmX04z-n{_n z+_ar#+$gpnne<#(xM6*h`sG8mhYU8W&!5 zqku3RfpT{%UGu%(p&n}vr*Uz+cZtA5rg_AXb{^>z2jzlOC#2I>*cazVH0FN#7JUD{ zEMS57@wQzo){_aD>1G}HyRVdwSIR`^E zeqh|WifS%XsFufWY8cwhe?&UKt?m1cNZ~uuwIP60j(l(GcD{ezy*=sQiv2ex|DaR8U{jAUzqxo%P-~&^$dFfgB$et?<7W$?f`WXZn7>ii1TCEl9 zGdXoi`L5!N1EaLb=HKY)vUHa*H+K{e!;^+?iTM4CA3{BDoxYVfK2>}A+nXw1P|xwg zD%WHi{o3k9Ga#vBXrvLhqGY0>B9)nmRZ*)Yf>rB51LbFfPMmrDb<=RmW@iwP@h-J_ zCT&ymrrx>!>C|NXc!JEF@5wSqWd9SE1f}EgMv#}_9q$n0eDVPx!U)X2Wwe(-^GL@^ zC>JcvimhyP38)c{eSNuiE|4SwzkNdTrQ5D>5MR|DLb4{#(_4cRbw7ZBPq#^ho*6NT;(4d~ z9?tf^4*wDt8)|;~YSIq<-FmJHNWq83G=Jd|V{IpHDuA)rOZiFtUsRoCSd)L;_7MdB zh=PDfNGKsCEe#u@f`p*bIeOA1JvIdCmKZ%0r5ouO-62TV=$ra3>-?VQ`B}!{Wo7_>6L$K}umvQeHw=L)riK7hp)%UKvL2Jo1|RH@bXi=4vj_4E ze&_F7Cqi>{<2H{JQ~j17#h-0skoACruGbPNEHi?I{pDZt^D2u!Y?g1t!{yFS+6Q90 z80zH(+ZtjTj*DYGPfqm$D8k7p4iY7C$*#<9yVo|7de;t>8NlWhu!_Xm>k>78azgL# z{eMg84ETK6FMDCYYkf6`Dk^UMk5l52>Teh`i`EJP)P6C8uQ%na6i5q=ydekuueNOD zVz{T7N|6VinJKM2}>81Z&3O_-U#cR_-A!V0nqPyE%DhF$bepu`sgPBIHCmmT8G7G9(}{Lv#C z;Q=d@;i(q+N+tpFywaU)@Fq0nq~^(()(YttRHm@=Q>MYp+&Q-c@to|MI<7vGN=KB) zrOB>8NqXC(Y*zj$la&mZ{l88n`d5I1gVq^UMJj~n+T^!Q5^avntI`MNB`bHmbWGIS z9&mcgOFK6giGxRskDSrCye-SMCp|6=ga*P zE6ax#m(>mVJxW4-khiJcw!n{h5f3nY7~ZA0=K1$^Cv!vFh2LCeDl&^jSEr`$A(-F` z{;*#<)>U&~zZz@)hJkEQ{`k$G#x=@vgFTS1$6>u}b;;M%{e_m^F;LF=C??RqN5+H#YV8dHGdCe zbLBWBD=iq>9U5_|vA(+dGW#?!HixKX{J(j~OBiZkA?{CtRvaYuOkT8-&KM zD(z@q_z9H@<(D@J+5hvb+xx);Ix`e_OJ3n&XhY4O0}{2-KPe;0vkDTh{YgXopOuUK zet}MEcAZ~~hR$Vq(4!cw7s!%ug&Zwd4GQopepVV>A#a~?RH|RXJwzC!kuxN6YJImF z0*cy4d>c$5!^RShSzmvieo+JjEGdrYi;vIRY}g92`#PAbdtB#&QoHWhU$5s?MHXt6 zl+LyuZ{*)~+l~T_)ZeLZt@=v(IVNw8{3a8%lKb{LDA*L~NUsH(&Hp2}$zOJ~eEjxX z$r#S;Al2VGpxrOGBGJQjD%M+~ZO#gO>lQIHrAWMn<^hR0U(M>4@xp0*fk|JqwF_nAP12;FPl zv*cjl2=^66@tmm>-=7%w!bgrhG(o z3b@Go>^f%~+H@7)#l;P&1X7AMYLp&|vWL#3{1x?A=L%gd`;g;~#a(T&kMF{7=8hJs zDDH*CMa#3{apL4}mOzdY)my`GP>TR5|3~UOfB$)1r|s|UF1Jm%91Xi61h7B5Yj>>2 z$RJSHN<}NPd zXg@Q`a3by?IvKQT_}+H@v8bE-mQe8d3_yOvCHso^VY8X`PD0teQdS!u)Q=;UHO=SEEGaBEA%2{)QlW5+E-=8%w;bdRPHym&%#46&Qd;my zmnCzpB<9lMG3nj?Rgd*s>F2y9&7pn<>u)1>I+_&E_;|g0?He;?M3kOr111uUYv@Sk zAS*UfbzdVHG@1BcxiXRp9~TNc4t{w?G6@DI4AQT&eSJNI1&hhT1Cm-OHdg^I@`kdGNIEn9Zpe@w9317T4u z-H)3b)P!p<$FFkpH&>oYLV1>V@U;k&F&AtGXqg;BZw8Ti>)6Rd7JyxR`lv#ExCrO~ z_t04B`4NoFR1e_G@I9jgZ-$EvS<)k^DyD{7U>lNR=@!-+RNMgE=L$uEhltk4hRx3% z_<7Q`Utd*co6%Q6z+Z@EN~R?;qgY-7WE7-86Ppg-iB?+hC90ZK&)G^rnfsK{CxVlB zIMi}eh5W->(k~}Z4X==z_ZOXxpL9w_DTo|^_B6U;_;{AU`(IQH*CQrEN%6Z~-oKQE)L|33q^-sMEf`riS0{B& z=%DWg{!C%_*#eg^cz)m?i|@QLQjAPBYx4^?xQ79ly&2pfnWMy$VsbfAUB`?pVgT*k z6Gzg;G)C9Fw^~;^HWsxGP!xzuMf*=Li(Au_NfT)iA&?8WJa&3Hj zJEHqU9cIDXbX5L1B<%Ij6I~k{E)}`xLp?)iJWtkA zYrBWyA0R9Kd?X5SOB$5#kTo$)%IED4}>03D9c z-n>z!%>L=4!wT(|s{Mj#TE8#yB{5%*=OK*m-D>(Ug|mnnBk2%%e!!p=>i%75 z{cQ1EGSg;F9ePR?Ru&&+mV+b^8#g7GQg-h@Yr>C^-lHPyv(o|hYy>!C&nsj~9_R#U z4@q^@o>PSB8~z&H|BbZ#c;9(u9|>FO9my8*|4N4^lDNmmPC1QGsGcHZ(S56k$#1=) zfm-zEWUtY&3SQFqclG9r5!ru>8%2K$110|~Tl|ClTxUO&5!gQuCHgd4mz+Qn5xw+B5E%UcY1kWyR1Z<1P$)f zakLcdm(=*}Hbk3w#{{$a0R=fgbuEQjt+%I1oUA+*MUHHsBPFA{CuDay^U4NtCBk{(Jx z%YQ3g7{8wl5F03Lxf_`u9?RJ-TUvwWNT`kZ@`H~@Pwy~MN>6LYJW)bfyTYsZGQ8Yi zkI@HX2wH`19u z-~&_1&3tRD&URt^d#*yIC8_7AALai1$wF%yq)+~v9}PC_vQ370?#M{+qh>Zldg*(}%o&Jd-?L3cCv$l}sN1K1 zA}{*66PR-xVMRydwKiz``KFc(d=4$k_EgYU*R%Qb7=%Sd6)^+wV?8{=92!!^kJOxF=H&x5t_iVGAZD z|6VyMjQj>m?^ipCOu;r10~wK5!be#~$9D>ZYW4?hIcFWtlDSx-M1hjYe_%4_$42+; z-%l+ef9h;RYX%&=6$K9cVRjV%%N*-ALb7Vk$~XGi9kH*{c=lPL7Mo99V+OgBD3QyM z*DjXc<=yE3o3UiEuUwU$&Ht{V#wtq3ycBTTLrIE2;lcst3X-ZbJS@tNZu?0(X&WW8 zWm?nf~gGr1m;t<=QbJvv;AJ;R;Tkzy|u#45%O|T zt`q5kI456-*j`xrw_|$19Z_^R^MH!{wDQi+s)#5A*$AFQ@Acj|hm3rrIJ(uTyN|Ri zGA3nHz)UjO0Ml{8tEg25)|ZJ2go@(3Jo$2F&fHL6w@^g&M}5JbLQO)bX6{DI*6{9r zn_)SUu;T5e9rb=C29U&gM8k2`Nv!rvXXWC#Q1U{?gP-^+v$ZvaTQ_-O7QR@8W{Y^Tsj~C#=1#6mbnKjs76F*9^%jCr6KyVEYLEYVG>81Cc zCg;ZsV=m(eJ(UU7u)z;mQ=z5bbGwPpALZ?7&-_?oXfad(;Yh@luPC25jiaPK7|GTg zSPI;M@hTag%LzE(B7Bui`inqaOxaDXklrI%@2h7QP*K6h_n(`bogkOdJ?o>-+b7Fc zTws$QJw+z=8EYLH@@8v=q0*X9p_k1b6vEMxA`%0~0J-OyM}oL7kz(KSYQ1yQBPV4c zwGWLeID(!rKsLDGxR#quf!l$2xh)6ag(la>#Qw4^=TK8+J0?+IH8K|W<%0(i$pESC z>@p}=MBRwLqW~hGHUGn!GtDIQ8!7DO;GDpN=AkV9wBBWMgmZ0chi75t7de{trkBE^ zroY_A&EmG7sbDb6YgT~JZGK~@kGdTvl3w`xbsR9W3RY$0KIIno8x>FxRwFr@2zV$^21vz+-I~3LiqFCMCL;j?*ls&>A(H<*(A~{lGB}BLbZmjJk{~dQq{nYd z%x}d8hHplXOdV2YaG~W5*!r$I{aQ$}qO@*hua>`V&SL2sf%(*e+YW(Zy%FmNQNXSb zdzi{~flg6txe*~py>9_gDmS>WXN*0ys=RWXB%}Oc{k1gEaSL7@ut+X4944vin)t@(BysYB)IQt3 z&E=>0Q$Ahfv3lrgDf?}ab{J@aseSoFcE@*#TOa+vVT>p3@rf}IcXE;a&1ppm{(!%x z>+_5BiNwfk!n{}9qd>@rnh@fm|KdAB0aG%hrHNh8nXHTcw6{6uJzIP?31Rv!u%a!w z8jPYbnezo7r(w$ywz_m<34gR0f4@Xgn7sVm0T%SP-Lgn)>4Xc4BruoH2J&oPwZyE2 znlY+SvoL!qA+9F&ETS2X4MC9eAC(jDV^o+|>6*Wuz7NvuN_u0e@epDYy(pc<^Jw+L zc)~C6jLG7xY^@FKZ@Iyl=krQ7xWxP^EqIu-VV|=-S&L5^XY;COX3@)1jhV$aJq9T{ z)r_-NQ5jwLe3V-nnPKK&5umq{fEze^@&N)C0i|VVu|}Oba_K61E~cFrze);}^Xe{| z(y?0{_(YAvag=Yy_GaHk(ON%t(%!55LRW6{U?h$|yIo{`Pw-1&kbxCRGaIP4%K1}% z!M=oSXeq8nPr=Uc!olf%(M#$bEi-9c5ler!BYrc#c9TEdDBKMy^O4g@R{Uj=#jqyr z!ra>{<(!MA_SN_JOG6=}5TSBx5h)Vd&m$>4#d}<>QR+kH5Lf9FbCKP~Th{RyA#HFJ z(|<*(S9)o86Cst@O1!_sONlcYM4kMDIj9;<6OR}s6x4K--vvai1&5XYkeENkvbIJV zN7v@btNFM~&Lvg^5yw;4k9)I~x_Qbo9}9TOh$Sjs4#~`SBHp+0l)vk0=}BYWPlQBc zM0&gmA`F{PSe*0eeZEZaL$UWVJzZ|T_ydjKbN$MLIWJ=%1*>HepZ=jo&0!oG2TYR}pCuZs1~ z^##+4uZ!i!t^zP4{!;@54eZE#UBPcsSF3H%&3%l^97hXY4x^>MRlkE>7e?50g1b<8FD?mD!&B zG${sWzbvp`tdqC$ks-Cox?r26w4c|xK3S0{rUUT-w9Ip%>eg& zr!bvVs*{J98i~o0MSp!X_?soG*6~PDKAEzLLl<_+r=oCQp9(m4=Rq|c^^5Lgy|}~< z^(@`<0=B~MwIta}t{c9OQ|brVqo-tnU%mn=U7IvgmV}F`UcNqol8|5b{YQ9f*5kfOLX8@O{w0ab=|01bpF;_uQh5;biq}33faJ90xaIZK` zmUyqdh#Xy_6eWzOPiC=yPs}?UmdpL-zPZyn@hl$FI{rVCoX>Aj2Jb~-Kxl(@V+m<0 z@IN;ifxgK#rPGWk#%9Rz;OK|@oPnEz-N8SiAkyZY)xjFY{ zLM!2y5;2(Ba@Y*-k6$c+?`@4_nMp0DxdTVV%unIBk|?hfgDMxFP62{IRcrSwL1X>$V{lq>ol<&OO>a}z(CFA zg_^0nA}C%gIX}1Q=A<1?ShXQLV3T2&xB2Kiu+^$#@?t21+@4 z-wuXRhu@%!cAtJq8q*yiPq0a(AT9P2#X~(NTP2`v1Ini*Idwfdwtsb<~9G^ z?PpKN%G#?0cR&s~rPiOKoJlPc_`ux85bz`9y;Wd?A>+EqrqugH3CZUV8$p7G8Gd3mq%CmsqE zMGj@@H!CU76`g)zCY$SB7H9=$v1P|gRQhA&-QN5+M#k#dWp2`r`~r)HH2ycvf!Z=| zzCWKI-ush178e=x^}Vyn%-UW}k#OrtR5>Fmc@zE2M(}9WcyJtvi5z|ghjtfnh|iDD zswGH;LA&|;Opu<>9Ya4ltjerqKKKw?G0a!t+9SPJz2_q#H(at z-2NfNPc6xFKyI&VVE$TvO4FRp&cxxiY}rL*7bL?xd+@qi^h4+<6*DD!wB_^aB2-qB z#^ghg^8qNN??>0B_X#n5#G`d>{y_7&-k@6>1$!4iBPihySD8gi?^fa%+wD5A{i49+ zmqY95-o)sf*!}O4_LE{;`*ZcOG*=GiMF%a7aTH?I+fQOxW3uK>jP4dU1eyn^WO4b1 zf)uZJDJTqr#fp0esn@#x3#a#^^?UA=ezy!-EWG;AxK*t5$){^k$|SQ-@TT0WkdGP- z=i3s$gyK|bq<`&)+3xaf5^)cXJw1DC1b8}nQvbigL%p!R%c8K5>EQ$+o_HO)`{^=B zJ>sa@bhuZa6TX? zx^;bbD4Pnz&b_0e73^2~d!QUo^6Vi#DKMb+!_Cnu5tksA}s z5v4VRWQ9Y+zU3sXPPhQR8z|qzf?c}6FZM6_C}Kk%ATnq3J1M{X4%d8SitCY_lX4Od z7U+v!?k?}`A^FstHD$9~8}{qD)-IVBt={^NhgMsT^IO1Yo#B%%2M^?Mnm-71!pIS7 zjqh+RHxr49*9;nfLoieMF~vPf(O+T`2KkC-C!-1isM=0;kd-ds4v3pOwgkUq89Ry< zEFNh}f9l^mm?-^NCTbEeU($c{#0udSA+G+Q$3|NFOy(Oik3VdbU*oXFMJ?!G`FQc} zHr(r!HMVZe0KWbBQB%;SPN-b#OMyd2j;xquIvRXaeJhN2AUxa5xG(axcR)QFmz$rY zV_G3RSV!1&5T^M6*vQ8ni=M;Zs%p!8LI!K|xcyLdZ0(;~cPvF>e!gs5tZ<%BGz@6# z6>gp@Z|>K$z3H&Z#Lk>_3z2s4irDbW1{op4H`V=nkFH$|Ryx3W`BCMB6yR0+b8!G7 zOra|3hz23L!d@D~Zy-iXsQ{o)6hga1zJ?P;t67S^@Z+tl3r&jjzuC5XhjnZ^#K?_& zi$iUnVpqOtwZ~e;4<_D!<&|k*C5c=xvxEe?`FR8uddN>hx^kngJ0Iq2=v7_RpK?Tg zxDvb8s<4X4*V|3m9+%Yq3&jP<*HxNyPLR& zYq$DzSipHIj%AuxNUXnnJX1ACi>>;Mc>nKg*AyymwA)Z$o!i!x}g-pDw!2+C{&^BiXrP!Ov~yA^T@gQgyG@O{;^Y z8KLs!<7zGN{}dv8c5XuNPg1q=SdX3dTnaJk&rxDY z`scwE=hd*v>p!y8cxi~uj!~`L`^z_GlSUpGQ!%Usn_+TkeO_W(V-eB5Ch#MVIJ@;x za+MPZvoTKsmA7rmv*&ZyhyBeW{UN@bjw-T`iPtf>8ssjQoRiG%_(=gBi8RF8#|m?M zLbDL56++G>15K@$430d1@x$5SCrwx@De~lefaE-h#wOb^;!GVs;ya4Q@0nz&@^x1~ za+3r}8bzS7K6cz)*u|eIjlY2>WRkVb%;WLh>2ZW#@YE!+y6oa+OXUy`+C$lQ%V-73 z1N~;to-F_ZqVmqhZr>(WIO#FJ6g3pC72xD(|Ed86J1_1HnLcqK@f50Qovyqer z&&#xLFIU_jQnLH1OJZCzmr~8P4UAA#sAupllrd(|X<;yR7%N^kw8)n?3YRXrYg63; zXX;n(A_3O6`7J9XJY|t$H}<7@V*HpI_A2i%%eI~W!O8iK`S4~M{;jx@Q+_;FO{+G- z7*^3KYy=tk@NFuNnegfR>4`wC?bJPz(+_2{H_|ovGRVka^)&SWY?k)`{6}_ye9aI% z)2JcucgL8&l3oAm*Xq7sx>nYKe`hY}A6NWPQ;Ho>3n(pa-6V%_X4N6gC!02#8oM4S zDFY|+N2sT3%zqQoYz)6tJAD7UUf!@a@NhpJe6vAUjIC;)bg3U-l$Xve2>AHBIubf2 z102k#R?BwJhw2OO*C5A%#LD5M30{->hV;dbb3}p6-A7J#Osp@;B>yfMo~12^YJJJq zW7U2%bDM%Pr=*kf2q~9!YZm)cN?xg6MvsE7$COn?#H^DhA)a^JkN&?d0LAOn8AN4f zl#1V={MK~)xvJ`M$eWazG*!5@n(5cadDld-sEtHItyIX>d%o>P2h;l{e)`lF31hyz zO!%3*zmQJmxDnF-E=fV8MB4W|JeoiL?(XgAS}rt+1y0!`?d6s_RB}0OKweyzflVi%cTtL z7z467rroRDCxNd`qh-kGT_8Ty6cmwI@OZe~bMZ!bXeqBMrQgT1B_|S!#A6)mP?V*H(7=F-SfrsB>9N>zJFM5Wd)96?AUikalnJ$ z;*SybFA>%Mu&Pl8W|WgSHDdD2Qmc9W#$5(0cZY-YQ}rmcjcIL*{+4IZ=6^26eVfGt zwL38UIKi|}mXhv$e+GYtyB_`W{<{9iI7s{eyjd3%@}$Y6_D&j#qEW+Rc?u*h%b?Bu z!EE-?o?mLjD?J=ne2CTsxzQB^Lct-R7mm1p-2SI@F$zRwOC<4fz8OP7ZdD+*vM;L4 zQZ`9+)XxZPx(V^ho6_4;&*Px&?Il=wUz4dryX}{7=Ox?l)}k%kr0I!z_LkgVbSvgmyf*kRBIuv%h2Z8?%3jpVDj_4dj*ZkXe-WyvTLh z&?nygUi>)Hcj-ck=b8$5rBE$(`MOBx+jlYDdoF?t--jG-h%$s}=L;;Pn1;N?zNf^Y zhX0j#zBARgYoapvI5avcY>P>M8D-8bCZc5L)t!OcG(-R3btmekuG z6v7X@k7C^F-{po_hQZuffti??H?Q^{H=&BpS^r;g)x8=*Z4Hd_gUOcF9nwcN z>|wwNdSO@mTfr~k6K7=zYYYTB%vW`hp{4Ofj1R`N9*4x{CyjMTmg$Q4zJUGySoRH& zI$QHXSu5k$_|sS?lhFgZK$SZ|uN@pOg_;uImrNaeirjhY8)s_L7wx5a|CEdOVQqzt z;}iB3j^~cAF#LIUa-w@o_fN67`a}rA&(wAC_hyvlSK|MIk@rowj$76gY!BRttAxUE zska_oF27jnnXgg-id}K8iiaO<7uyZyW312ugE~Ywl`E)oE*W7#G3pF%Phz0PX<4h8#TAKHfY+t!>?=p3(WOwCL@U7zXeB~(*Z*&Sh8AxC zO|&PT5Kav{JE(>Y7`wQ@_IOax)qH&Y{5;?y(HVhDiE9ctkGl$=SAk8aECGr(VJpL< zTI>qfD}W_@HhQUbuL+61%tBo#q~C1T5nAS@Ape@2QJ7)a-sJ`C!eyy-*WqUO;JWol zp*p6KnGHQ%-b_bH_Dd+0&+oY&0goSZFcft@CMa)un!ZH2P4?~)xAO(JCX_WCl}8+u z7or|FsID=8`VciT>~9$fArO){FSP5y)v9_Wr|QDbkq^g1hb6rmt1{=uoTTZJQtaJ ztJIZy0;A)FtzS-8D2)@g51l^CeSU~kT(zl1RP;0oe_6P8>)SHGMqS}O1J$&jU27F< z8eWkyLJ#k}(95nwH&ki%;pZ`)*>@rWL7{BDv9#{Q6>Nqz&Z8S9wAvh3y3&K?hC7NA zp8Yb`>gEE zTso8|Z|=(3U%YxZl8#@hnKCzqZqbP-!(l-78%6wX%72{|Dcsh{{RP_f~> ze_Ck#CWiB93$khK0>WSs0oR7WHE|cjwV{hYfg0z@eFF z*hkpwr_M$$Pf;acD9z$;Y_N3Vs~uHN za%A&>uC;&WRx|f^kE*&?*JzUn2uc(bxZ z`8ML%kR*GCgjFcvK+$d3d@4w=q{eZ6vI=&d;R*c~VY0|A$jK2iQD|w*NxQK}(9o!AbUqbge-X2p4;zA?4JwI8bNtDQ zhb*G}TSv?JOx2kyTWq!*TSv5F8b;NeAgzgJSSC=uj+r%pE!dAm$OVq<>eWC0V|lH_ zp-i?oXf94?|0MC_;E7wxibMWN46=DJR4wRgP@YK#c#-R1D?Ps5k0uAlMLI!847_XG zr#|BpQ1j(r(w~&kqd1ILh0XlixkpZ_>2v52AiBvIPX##mvOJslWi@#gMiR)LW6}hr zNd4E~4iZ{lw9zSnT)qA(wok5_>D}xNxn^H}SKPpgm}dYwBeeHJH-i=BGB@c1TvjVow_Wf&k@J=#iI5au&B z*?gTUIubvVHu>Ag7LaiAOy{>&zzZkXNo=`CiFnA-^^Oj3r8fX~z;I)XfL(Kg&a2O1 z8z^1GW!w-IWK9aWcX>_t3r7*+fVg~T7~Tb+)d1a#Y_YI1TS7KEY@GY+=@5ZW@f-G1 z%10pICz6QBqm2heGDxq}U015R6EKu;<9C>tW+nsIUzP;|irUfo{rHP+<2%q)mQ?bPU zH%w_-RfS0J7bv9^0nm1#Hd#?uItA?d_!XY0ZSSqe5?p<=BI53cM(3QV2T@t6{=rho z!5Br4;(i$+>9mqvX%I$eOMjQL0!=PnD2{OK(!BYDRyj>PhU%UAz0)D*bloy+mf2m- zfycxy%0ccoI8eFRz7+t7o_QPY*=ww=4W1r*U%a^)+pOtUJufP+LtvIv!pt%SRC#JK z+WS8e?y+XxZCMKG1TnV2iu^EG+rEDgErK2~!b4!KSpa|RdY^m=MBY*KIWz?wzfT(kjRaV9QhN11M+89=Ml7WH zA${UuGO=Q10Khr+Yu(40ILp(G-}9-oIW7HlX0``anSvs?9TKv8>7Xd)jVR8VW${ph zT6amk%3>Fo#(&~H(@v>1K^O6{kW_iPf&W@}ctCfK?UH)-sQ;y?iHLHJLaV0aR~N!q z2MM|dxVEK-0=_JA;9X|$`_SSeW{(ie<N zjL?U)YmG+gU?%9%Pb~0)Ct%=UnlJ$Jx&A4P*{P$(|J_3NSR=ard66&O5YQ;XF*~gH zz#G!f-W+fthnyX0b!qT!2;Ga^(;V6RFKU`67XvrA?%mQq2vXf2ifaYpuXF$)U!+z- zvDjH;4bA%@Q*RSK$=KljxBGLL%HZ{x|$xBuMC#mgs5{>UHJ z=Fmq1AZXryD-e&7pBW6L?w-M;XqBbe(M@vk7DKUB^?AeGUc9^Jm#wX^KRv_pT~)6w zfzcCo8ul{VTP;V>(wE&@c!n)FBlF($rnVVp>d?mir7F^0#nHm{=EUaE0DNF4Sy<9U zux|@a-PzK}+A4|gCy$+2+SP|v`I0}?bD4qRIr0QTmY8PUMm@IfSylMt8dh`*5A8c$ ztSoIwycKvg4gWdp67#A2ln1?Ol}LsG+?>DiH?1^J^JLKJ#m#bJU&^|j&^zqU8uhl{ zTRZq$q!109QNiVn7N3S6DKvy$G&xoj#lLYQ5N)ZP`=d%mQ>yT3-i9lbO|fddZ6qM& zvKhRFJb=cyZQq+VDPc4A9fV8zY@ye^#Uk1#=GXnp4k>GqI4eAXekxu4gA?!fq>i$I zf67~)`y+KCx4y25Cl}*oBMQAwJeLI#Hv+qCm(;<;S3zu@K+GH}Vsq7;yy7c_Zix%~de zjj~IfR7@c|p2N$e)kfV};V0-v<{&25kT_bGsa!N*q*R$b)!jbgQ0!gw>*ug=^Fp4f z2#?@&7o#&Fgxs}~{%*uIHLsFJ={N{E)1)S7TtoLB4 zGl6h?M^iIUNzc%VU5aTxzsYaK|8u_ShMlWB1JU3BO|XyVC>3Ox3Wo#{bn9*oVYq<0 za_kl1$({FUP5pkRsF_w6v~HDz*{l_4!~mT3H@$fk=Q8~)k$8GK*Hma?vd5do6?4Y% zHq({BaqO%GIbpxC7&6k}F^u77n@!s&e{kKjeXhJ^eD|mPxCdtSB3t(JwEPifE#E!# zyIu+)qh3P4B*U{cjNahE{Ub5`g{^7-pWnXl7ozzkaqs>1WLmvsGVFg}2>@@NPRsyN`wakZ`IfnGl~z{ZwSddWFO8c2mQO3KHKO14 zZWv2GQ{K&=zf3_!yPgAMA4@WB9*4y*tA2>+n=q*Iv$H-Y2boRBSj>2B5?+lFWPg-t z$$+1mAa%>w#s}1>$kEuO-r)N0!mTD-LcA&#&tcjUf&?#@>zJpvA2LtT{b=$7ZQe$) zwfj>h`VChsJ9NjE-1U~m8_NQx1mHV3Huf{?SUJj`FAOYI^m|N{HWC=ckDPqUVqP}5Jnu>G9`XJQ#L z-#xSJza+6DVZf_r55fb5G(|`w~yvs;qneegE{<;ZkyIp@pUe?ZK6Imr3D~ z9OMA~-}cWKR#>SDQRU;wv*KQqUFvwswztyOEbjVyVbaxRnCbP?zrWhc8m$hy4lO($vUEyqeP_9 zXbeMzi>%P1=G<2hj}oo(RaL9U64X>pP;HLkzp@f1N1`zifpFY|UMPW%2+i71hJdBF zUaoc93t7LdulGoMH5D?H^+i#C7TzE}OiGOevNxO$Kb4^vPXCql3lDW0*+5g;jS%8j zyi5LtfNJ^^(v{wq65=Drar_(j(=Z~l+GBw-uAyd3yieC?Bi`M+o7kQ@p5Zn;k>Q)| z#ZZ4*e`E6!#4mUYrv6Hn_*E$MGO7g=kpOmdnqZ+h!e$9vg)|>i{>>cLUr8Puc4uwi z@p?H=FDS!+*1X=QyQ@O|^0WH>26eU}K35A*$>#MD{EMIF+IQ8)i>f>(lklEQrA8;H zGmh+;nmkk2mYMl$cO$J09QLMXYpHb~O-Px)E#L(nVS$95Q4d^>Y#U1YD%mrPFmr?* zu0!0ABK8E{JwG&FtZsn04`=)eTv8Fag3`ZbnAVwHFT8&nzd)@)^iriz7Hb6uN+X5f zJL_9x4H&O@@RTl>=cDbAiLlqZwWg@abu)fht7@WdqB}hYIn-S*iD^S^DcCm z?9Hk-H@)tcB3=?`0Jj&bYte?mZmQwvl06Pf`1~LlG)&F{tg&W5xX=8Oy$+i$5aUt} z$FNeWmiJs&v<@VIJeYv%v@5Cv~FhP!Tzlg)gK7}w-m`}00UBCuL24gep2hTs0^2<%VOkffUTdAR=;|ACBi z<_RJC*g(C=@+7@nZ4q()9MVNzdbj%ji(ovlp?8=*uA$OA8$%iT8R3GShCm)gs_p4l zZvOFm|K1+{MiRtyTu0TwFnPGj-Z$Z0^5_xZLJD>D#uIc;<+%s|$(bBbaF6=*)xS=b z6!GOJ3T5XOEH>vUwd#`Gf8$@JnKwbgG^Y-3BN!A#5}~NBOT>fwtwgOr<7>m_yi#rb_a0kBp8f65J3*G5hwU_e{6b6`@2$c|rL^{P~ zzgYpY#fbrx0jH<38^H|MZ7)8u z&80_$u#FCD_qU^>U>3j+vp1Qq!a?lcg7$DTd#qm4YCHA(YEY>Vzt#SJ`&|_kIS0#N zT36}*keipw=Ioeeg{?U+%#PNk+NOfO&`$4(d2=(})I@UR*cH3$0mn$6ncw3BjHT1B z&;I2BfGtA9jZ4{$1`39ExG}|3!#uJ_!Q_M!&t-K28t8ZZ{-*QB%YW2=$-7K+$HUGc z)_3a?O!WBa*yWj!V_NB z3wcotj>>oSKyy!5#8EcIo~ieCe<*;8vS-LUr5cN6XSt+NZ($G47AX}^^jm?liDMg! z=Mz^ls>cC=_+96mL!Ff&(W)X0{d z_v~K&+W43nsoP_n6nq$U`*0?GwD545rRyd1;u3xdy|i?8czeO`T=<+=%Wb6Mtc?S=gd8)-iG7AQAse8>K3%4|>n)>5QM%@&6lm;=jh zod^1x?7g6geBLmZ!r!#qC5Q7&IRqwn0%VYzvU9!#+MRd`^3h7}exbzLMaY{CO@rG3 zko+6-T;gyg1Ijz=&4U!)wbCQ|BS06Rz6gZOvnKjwaQPb&7I;67)XHri^Ho7AU>K`k$-F9(9YK{VfoK1}|*MbEb z7*O@maoEj(Q(h1A^+s2g=cWqrqdmB*>#A($M^hk&KglQUTC<^|Rd&WFal_%xqx`{! zYcm_$SHTEbf(7*CwWiVR?M2%S4!I)S;W&9{{{l+LNo>jK{-P+s@e=B$MKCERHpzvAvO+)W!?se0i!#|rN6TvbcM=#C%m~kV8^&{E&TV7K9S~==`6?v( zr^0Jpy^xd(X7-isVgWiza&y=)~&!+OwPIW@L$jE?tJPXx(_*d@4ZsmUF$%b*Ntyd_Y4na!ZgMPoIfXdz+aRr~KXTokzJDRc@ z?7_FDJ>Q^5%cWp58P14PI>5^1loqyY{TU=J^MuPf6yg7B>H&jwO9W;l5^L-NyD(n*jdXJzDJhvt87W}iaLzo;U2Ks@ zubWd5VlKBm^1LVbs7PD-Y>pcd`TvKeZ~tfV|Nj@E5-Le@s#KI1$zcv#y%b5&ksPO- zlJhytrc{zsCFjE`N#(GRGaH6EZq8@sJPb3llbycapWE&G2V6f~x9f3xKAzY8*uBvw z{IbcDZww;!psv}Vp4nTZPPkQ__FPUIf&kXXyN@N;mJ)1>qlLe?Fc3BN$JBe0nILT$ zAWG*J<+Jyrf$ z=ZegCVp_rMShK2)id-GqhlKgsLFEg7mD}n15fpx&9&95@hl}*3kIATq-3OmsvnHSc zl%hCcx>=3&8M@d?@}sf(rAHNTC+n5o!O2IIiW!H6S@zOudK9HiZF*NoNL5G;g8s46 zS_%=3S7C!I?zN%mSPm;9R}UBmmLv zAUZHc7AJ$!dwMsjrR!3*afsElRJn{U*KIr`#PsdspRL+r%f`@l^v=ar%o;uyHLuRlJykX2&K1T)3Q18yt zA7;g}io1%g8A?kw&B+Kh4fTZ;vsgb9!Q|#Va@YIW`yLW<D^PhBM^mLHN ztjg-5H!5OTtcvdTa90I=`(iR${q~DOX8er$Go=L7j7)A)C9kDp&8Un#VUb?x0f%Rq zygG$*wa4QsjgVn{g#V>OkL(e=tQi4jGI~6O%17g+S>~Zd%<&^Tf$Ut~)qX?$ImM7TO+Nm#GC#mLs z1LHv4i`lV$sF-1pBFZZ)GTchTBl(A&3AoyrBEz77g>K;znvQ7^`S!vG1F28|~>4&{b*yoEp<+ z^Rbl?>jrE3gn9>mFRknGs6x}yIVvvIirZJ$@wsfLs8pvqT80yN@6O;`^3BgLtan?? z;4+ub8I%V`v2nH)?u(pF4`CHt*UMt(vgCV6MP!@;Pg?43EdLgqIT6G-%BF&d8`T7^ zJ$!ltQVd_*0p=+%zpV-I^|z5cJdC}8=e-SEb7$C>7I9Z-y(-V}&uNc0{cbz1K+l#- zme{V$N1~i}zQ;bHG%)>79<5?BP*kqw`AR3ITF%weE~0uQss8G-Pu$x-jU`-ld!{lt z9{r1UDpOvv%>UuV5ju3+@pJ;YZ2H* z_!}R{%k)}&O~qpalC8}HRBiqzLz`~KMQCI3<>jPrdcur9JMDCRY-tTOXs!(#*eW+fmu`*I5yo|iuUavTZslK~^WNwbt zuxmo%?@scmyL@%7%%xpw>m`sx##DZ?vzUx0nlm3t(H{Sr~U=Q&1=S~RG1U3 z@s&tZUGiMf)l{?c$~x@Jl0F4^mmYzH5#0pzH6+ehytj-9->rWX-R@9?aB{dMc|y`6 zxSaiDBulhnhH&WIa{U*}(%P4+`C7Q$+1)2Ty7cE7t?}g8LGxSxewh8v))98va;#)k zRv$azgfg>@#TQ)58=<`)I%9C7?9eIJrRA7K#Fr5!M^0e1VV6&J7%8f;EG z+XP8x{^+*f)18IQ1FelC4&{(RC zIPll-g1bcR(>5M9SN@Q>kEgeZnAcA_3nJjE@3rU=49XbbpfBc`4*1!D&}^*t@s|;H9<6MKRhk4)UM5v z$^+J3vHE&zsYu+MSS9nOXT;Cr_{WVr{=+pTHlK^8>KX9?{B)kN40uRk7O{WNnYXM* zm|=;-jctOLpZAj-#AZBZbfQ$e1DB|~3Ny@|ssqZSVld!^@TG%*3W6^Cx6J}nyEODG zBVap4V*vz;ycn&s$yJCl8~9N8>obx(@@VK?YB+NT2?MrhuRZhlS>h~9Ti?IE%1)^{ z+P=(AA%E?-se@=#o17=_Qn^-IgCtwoOKr_e#%#-~Y<4$0r_n$vxLeh-OF~b+2le`1Z4fGxnKqL9% zwDE@d+f!Ib1{5AF5#1qBZ3^9W_uT7xoEXw84+^y&whkZo_Iq9z3H6!+-D_Ll{mUqb zw`SR^p@``HeG1_lzh}@Vpu23szI%xsjSn$v&z9!b_PEuBeE6h$N%Le%`gv3)`#XMj zrb9AYwXU5J&r?kqU)hc?WJm#j{5Es?!F_G9`JsEUZNq2r=BN|);HBs*%GL7&1uYwr zqNd}!zXT$hY}B9cWvTQ+=(mv^={0JR9gTF9uoQ%Qa9I3R-fx0ZmUftfmh+rjs6&wr zlgO#FdNDQEZErz9k3d4U10)IF6F4AbL3I-5l6$7AQZAg{Z18>tjr&Gp37#%>X1+*p z1l4XZSWz7t*iohD{3h})GLH}DZ;nt|R<%ML!+pG52shgozF^Oi5!&6I_^Sya@idN~ z9?j1D0@<)|w|mrt>I zALCzcI?S`iGYpcXbgW0_r?Fh00B+f1lwy8Sc};Ybp+<@H9izstTcH&{0TZzs`30*j zIoL0;?)09ob>|-*wQasQBYCbJ-*aH3XcY0AVlYp8@0II!u4AR<-&t7j2?3utOo!eB z_Gyi4F4@Yr*OQ7)CbLD>^>x3WEVJQkJ2u2y`F*fDwEFp*cT^Oo@vyG;`>{)qWc9&- zn_YN>g}sy9=g<2E^#ly_=U-bq$?=-u1cLboqldFae_AbEpIr?U-3IL2$@ZTqtZl5n z&)!bWwE+3GwSz40y{G-GDv=50SRRA8H*jC<7?O9t9Q;Ype%ww<{@BQqaPZ-8`7h^p z)8-JqQ=ar}hq)`!;-UAqF3yBU4`|OqIx9-!&@aM`D(~@y zV!$t#p$~HwRAO1Ki(ut8K9j$W*k0YAw=n)n>4d^8T4F1nA47NqAodxR9Q zLtc7{^`9ibuTvBKp8#pMmmWWJk6z$@M0+37Y+-ca7rQP$K?nIctGq=&hH5)VpwVEc zP6XZ&4AcDZ^l*?{&B|i^GDlSH8MvjpA*iK>4+{po%jM+tUXVG(Rd0a7aZZT_dk;S3 zOfYuLW}o*Qz7&f0J@x9gP5g9xj+%U%KpO};?9KDwEn1cEo5}osew7tJ;3Z-~DM)n2 zkzd;a=B7d-*>Kc4U<$R<&4Y4@2lz0X75ls(&S?_e{E&J*YZCE@;#R6wq-J8|M)eSJ zL|nM=)2$oY`wj_K`fOnj%OG=%iiP zS>;TC^;vATeHmB%xG3~!2uR)5lP#$4hm~XR3SSa%eb>4(uQS>4*4Q}SvM`Xl=ODX< zQN*)-L8hDojJ?5XK=%bfR_{8!*-UYI6Kh-~W{Y;xi_nX&`Us8-j=19FL-@Z2_)o4W*U z-Z#ci#Z}489E7c7n!otLoS)~C<(|JeRmlDEf{GJqO~&QpI9M2QK$85rSLt~O{EuYU zG-n{A=F_A1Czi2LGXtmH{xj?4OMk2Lq&*O`SEW{NQx+zwj@0D9@juv!{7xAHY#6`~ z|NDOvX05>dOrV-$M&nKg`XDCuuh%4+ZjxaT>lauq8eXt-lSU6g3Z=SA&<&r0%FC;7AjNcaNUKvOw3A~TMs z%;79-Gm%v4r!S99!u}f%n2p$?R(Pc-2h1omx1zMY_ zum9B&xXrSdm68`V1i0jS4rw^PtCD{I!4M5&me#&qLDzHt-rmqGbuOwO)nq(_IxZaA z4}dYgv5^1x|7yK%xNUzx{rI~ldYV0Z?cmzCCh5tdxq5u%&^xOgD7>qh8hW?6Tf%oc z_`^ml;h*C*?sScMI*R0^yKt00QIm2?EgU&h5wU0K9gH_iEA7i~8YD_I6!6bS2We47 zc8p-nkOoH>FiabRhD#}Jam(^sWGQZzZ*-L59pxINaoL3U7yMVkYpHPF-yqbX!h!H! zHLK4R08`Wh9n#X_uNJ1&#CD7tY6vw8NO0x|P$Z8%-+fsPCP9y9=v?v$y^~v^3HkL_ z=jX}h53`RJQl2~xcBVF_ydJ>5y{HW;aMIdP^xP?dc_up&)*k+(MP8KuS&tn#0Wo<0 z=i|NN(gjevLADF-FWjtkDE!qb)gp@C%ri-s?%AN<1QUb&&?GdsQYz}y-XDiQsR_;` z?a57&IdNe-hTb8(pT+jm7m$}skS61KY#*RMNZ{b>5y;sOhzye$=_W}y_(Qr;ob+Qt zVng!lE$(>RxADTLN0;w6W?sTGFy&6qE@_4&)@>CCTkQq#334;mDQ<)U z`rv3Ml4^CQi_P2FIFPHRsVAR9hD;w4v?J~ovu(^DHENmeW`Y5#WygjVZhmU3!5@7^ zDY`f3HKJpQvn)e~R#~nT@aT{8zMP!;^C_ci)C6g@Pqq7rcG(2=dLP}M+CW{4Ios3PspFv_&2r5unOCBb* z*^;vnxc)_8ncjTiJDubBPOtDa2;id$Dq8*C;><%zW%co}`FAnZx|(>n=rz5~)HusM zaY?ZA9_f?V8L}fyecXN1*ai>Ca>|>}y$~Cso{=6HiB12}RpGSq<4?Ft_|dz-C;aQ} zGpy5B!GO|#CvHG{MVO90nuZr+uZ$m`F}#cvJRdAH_T@wH*7m@QhceuweDmmlW7%knW zfDCaG`k#LEIaHUnSu82gY1AQ>dDO6?kvdc#s^ZQ5ZxZz}?mmAhte_sgnN(f$C#buZExdg|MDXCj za}AJF6Gx<@m4sAMBQb@SN9w&R#{GT~%UF@J)cL%wkKzsoEhu%q(* zV3ARhjx3klNAC&vnkOTNL+{y-VNv2^n&Ul{QABK@PjWI;kwHnv=~##)#;f=d@R-Qp zm}AJuXM1!%4b2+F^LNlUjfmANhZ^RrNN{0h7I;-H=EfvFa)4wIAh-Gv=_Gsq2aTJ5 zHzhPKyNEu?^!YJk9(k`B3OM2=S2U{lwf+zohQwHL<|cJn`L7Z8R}ElC{7NOP+AdUZ zOS7+GL2?g7{1c|O9*UK8B|BQzLUfK6MEppa`P3>p;dHV=`*_W{KDgyye4jRSrLI|&!khm8u;BLuLNVW+o7@O}P|{~5;T#2BtU>_4;v-{%|^nPH=}vb7ZZ zzFf7)z8hOU(Nkuem8A5b1!Y=Rhj(|(_Qk4RXC#FTreDo>QmlBjJ?U}v6IBV;G_a(b zG5%3&ShvogWnv>~v+BC|r$mdwF}0qpBGO@Gex33VQ%WEg+gcOT_Qd|9QzX(c(kafB zU++U75x?3l5q@=&l6j%MtQ}LiIgxf?c%uRgu2At~b;@oSOhr6Fl;GQ)p`Z_=#rTfM z$R^h6%|^|5aQzWJ*J&%KI}E=ovcLUfpBIA0LP3G-uS9$Wlf(voq&QxtmMa}RhX7`E2Yk-HEc)!QWP-Nmc)p@r zUN3q~C}>MTP!AX4VN=MBw6ayczbleUU#cX+gM>{qT?IAW*MBsh2;wi z%}3LGH3xLzL|`aMSN3W?Yckl|b*C4|qFGB(Uq}hzpRU z_HPxHQ&&5Ik8kOelZOmzeF;5@cYK_6&Z!)knZu6I^w&>+a9oZIiIx7%zV^kaQ*XXh z&2RjUB(`o&Ic$;2_Dt8~<>4ElI~Jf{Ye68jtrL(}{LQ^$o}~&9gYolxm2N=LZZV|t zj@?_w+I<7(g}R;cZzm5&Lri_rCMj1&P*3Sf!S}QSRUU^5T9$f-Y1D)*eIq-%`lU$f z-EO56|30-GHMN!QiFHe<6%8|X9{+h^oa@~fa^<^Ll`Y-DcEBePqf{3ckjmE~Hd%6S z0E3qR_+werS(Q;U#qh=LcqjUNH!`YC#UYQ8gSg)U1mBQm%JJC*bi;jqYTq6_0%_p9 zhiNq(oGtc0nLId1#e`3@RXytE`7wiAYSoW`@c%$#uUua1rLBw}Y(-I|Z1#&uc|OD? z3rJin6q47oqnBRHm4$@Oi$xOf=OU*tZ1K<>GQD%hfLzdUB5Sfzw}|aMa#wRPiFHfK z0k$t5pgVP=c4XMg+sQ|+poMg zFmApfE~N>+ujy#WH>my@dGHa(NnZ(SB3XWH%kK`T*tn*;s+6t1bV%=DP3APKvof>h z)#7QT;JjtAJ|`EA?!*^2jXSPtt+m|+f0>np{gtY0$h6`4?7qq@$?(}}EK9$CQi1C7kt2sOPL)m}pW9iI%UsCT zW;&U*fK&nygRN)F9==NFZv*N%drMIA%ilV@Xp|w(+a+S(L$*Av|77-4W=5jfbAYX) z(6%lV>p2}6s9iw3P&OPd=+J4oK1(QzLvNqKwTouUAC+*>8vWEXrGOGEey8Ii-)hax zvwd~L|3utOQ#G_Ch~H{s{5HJTuk&Hr>7d8r-Kg{S=8AJlqRx5>IVwhTl-(2#9Jlls z$z*o#l}03CMJ!jpl{886E8sBJGMPyR_;HrW^_)RC9WZ5R6w$745EQY@>|vNQiY@5| zcKw(}1K9b8*C!=VY(c~QiV`1+%XXCW)qG{82)~^Pz+w-1;P)o;SEHO4*B`sn|9iO5 znua)fsMuyU%rw_W#P=*2K4^R^SmH_qTlj9%G%aT7NzdrQb;QP>lxIUS)Uw;+U6Qbm zDamuGhNE&`rn$;paehnB{IptrTK+dZ(WPWU4Lm@(C-o=x9|3*}HoiZYd1`iDS^H644994DhYK|Q0^%$AbF)`&f z8CbSC+i~_qzSQb0V0@xJqVS@9>4>X<2XT(|B=*&X-BKwqLMI^U$?3YJZH20StvQBo>)+AM6LwQ%QQQ_;zRO3=z^UT%s$ulzi8=fm6-x5MBm20qI`pF^C) zxV?bB)&T_&OyS@8fMLWkp9bN|0noE9aQc$06};=Eeqn5*pjG&`6hMi72G9Uq21Knc zWs;dy>1b{k57`ao%kU>Aojl&e{QN}4$ST+t20exT)Qq0mI#zLL#2XjE#^Pqb_B?ub z?WB80{AiBjZ(aMF%N;l59ahywS8O=FsVp%iNT-4u@71*IQ8VO?=2z(DPwR=3P)g8| zoFqr5I*h}x7Sj3Pps|puI3hKyG55V+l7RD`(S+oolda(nf(8AdHY|N<>drPPb!~?T zTDiY9*lY^0iwe42?S-Ep&fdusMtIU*-k4cozx>9b|ELZ8h) zuO2U3IC!lu$+6V3f;y#Z2dU@9@+pw;X;WZZ_95@bsInXj8)vSpfP72nmUVUrQ*Zjr zgPAIvhSGbpl;?%K7|7Ceyjv}?NzNl2txx`{uz4we4r2#CurGQID?Pz+u@Fohn)qjV zMTdAR=?Fsa2@za(J{@3eV9N8Uq({U!$~Qa|(_fSjU*A$Ui>qJpQbL%t=+7U|M}_h} z`~2(9MLc+e?tb*Te>WI4EE|kp*v!1ei96T62$j_P(@F@^!T#H9lQsgaQ6gfkN`3=x zMOE^KI9tIK^g(t!fj3+Q)+`zH-@9V{69m&;_O4j9=oK*y{#Go{y!7Z&W**K6OE7R% z+N&;wj166I@mLps;CNYU&?X2_f|^*DCcUGj8bO24rA2C0W0D%@JN^!!>AEK}um?gJ z{$sx7pXT!Jo&s@{x1JN7?kiq*pho$DW_fcJ!1>5;A%asgkB8Fmog<$V{5SYnWc<)3 zCcjFFdQ>Tp^%}yD2jJnG_0B-7eYh|)NRT(h-~rOXJMH8FfDrFfQU8Lmx-kO_*Lf*Veo}CK zc5eL=M{`~6S)TY`5>IF`7u8*@k#_f>_atQ1#p5d2{av`BM@sj>?L4CX!ewT~i1*`^ zvKS5DJ!PLx7^;7%;=s#vg#&dj6X8)8R8#~{Y9^VXWFoWC7cuBmTe1rtK1e1|L&Ia9 zOfjnJUkCAYqN|7NoDlG1qantdjhpHI^m8;iNrrK`FHz&PKdzOBEqsFCi=QYvn z^S!9r_=+saOwG`gc)g3QLN(i%eF~s2?FUt=wkhHMp|Y6Ps~N&BD2l#}dZyc&BvL=# zC`pGUmztYs!D%L`625Fj9z5BlY@YS1B-ouCwVsrC`Ob^8tvC<4V%d<-;t4IChude1 zICr%nKyOZ^$M;J-=oR{E)j{wmBBQye?VtjYXv%NUf|Yjr%3wU``98S>`j4BEcJ-VpiM<{iU0H#M_AT{E;$JF@v6)Gxl0pqXm@`x<3O!M!FO6=`8_13qqJR~Y){R8k9tR3uKuRuL?XiAT2JGF z%WN4x{R6SZV@+J=?G`9$UZ82|rV{6McQqW7&Lr$2_*=-&IT-1eQwxf<(1C6+XM38! zS=w9PMUbey^A|Lk+iW|ZQ&QpXGqPds4GIF5hq)oZZ$CxR#6Z||so9v zm9b)^(XaY08G-Te#K8_atW8BYRyGNPk%inDtB1QL&xNec%;*mz!;j|I+S&nk46l4p zAmsR_E)misH~-M|pEZ(Ke@$Ns-~KhYFI|ZBQCP>fX=!$!tyZM|80r5OX) zNnKu^GIVmz>m^=jtJNer^BCX~_AG0QdM=^)PU@&g!I&E-czQfo`zhWj>9CAqwi9-@ zshB&7Vbvf7i2#AkqOQaw%y52Y}z>$#-%Tm)aXZ zDwuRm18k<~( ze%>f$UIFqAIV(227?&U{FC}l`j_(X#F}dj!?36Yo{lRP6kbAvD7BZOf5!$2=$LnWu zxCa%X`Z%#Fg^r&xGoRrN<)#pO_j*SI>)FMCbBl>^2=6~vNb~*bt+s#Al@wwX|6(0Y z8Ka{2j#ckO=@n6CJ#?ZUTfkxW=In(qh_wWu*y6OT7`2EY-BrYTkK;{#flg1^`FG>I zoh)|n0)WQl>@3}HF16vTUf+@8y$76%i0z9BC#nSdrIza@o$MxU)p!1WXVh-7Ln+gj zBd8j`@ZwpJIk8rWuBYEobyUC2R=hzE9V_RLHF(Hj5XSQSY?tbJMWYZH!KPttyMYK&^SFBp zCy$K_B2w#dgdA!ai-Vi%u|3?U0$WgOj*1TYS8x@3{gUAiAw$rEtZ(WcdCk?POlJvT z+M<>2l2yi5za%14Yr_T81=&65mjK+f0QAQFGbZ^z((6tv$&m9&H!md_gZ#IP)pe3n zQqdiySSFoVy}0GEYB$86Q{y~yK>NO~ZSJHTsB<0ltw{Bg$&Xz&w$lfR!BxYHr|6u{ zxD4_526ma&wSWh)u##)0c8 zV1vfK5DkLm@_o7PXB4~jZzgM-a$?qjCjVvAer6lD8L{MUJ0&cuhBA_GmN#lf@?G>y z|7Egmqqf*6>=KI%<6RunSY^HJv7QY#z$pDdxA$TVeLhl=Bdh)CS94XPv)^n8=J^n> z=z9->D;bKx5OH*rt5r<31bzb@fIjvK6fB&UQ-7BE+!nZ~eGk-QD2bn-XCl+@Xfwhy0g8b1uA!ZUlc262NjqZ`eTc zxEkaY?F}lnKP4&&fj8p4FrjA_giL{Q+SZPkimYhmC5RUgGn6;3<*g!~`lGO|33&{8 z_CUT|eWcXkhPlY6qH$hGn)Ys|WuH1o_gzAz*|$Qb-==C<;c3EJNlJOmPc#8Wc)3_4$($Aa<5E}Btq4LAuPG|%sIFq0_p(VAV~pArx1QC7?xZ$NGd&ONmWFr5 zIAeF;qX(>eet0^U7X}#rP`)F_x&?B_=w8+b2BF@6)Pq(0Ii)fj&Yx$0wxw9ebOFzu z(2#YKRx8j-lyrV8`n&osB|5-Vx>ss|dB^b$)qwN&!DyYch#a|s-~Z{xjuFGz(C|v= z=r?_$60cd9z**YXs%zl9dr5h;|5n3$7JlBib0j>fYGz@$LkCyDgf?N!{r%ULj;=(+A;Rim%1l;|;rL)FZ@CE*%Tc6{%^0ckrb5 zBL1Oe6x@RUQ-uIm*;-0}rq;VV=Uc1tFjh>Vh014kK}ObHTPi()!%Uq7y-Dm~%Z$o# zI2JIgHB#mBDc3JGBXl>Xt4hS(@G&L5<7?-f->KvMP2aL`cH^Yp3w1_{|%-rttq>qfyowaroUKFVLyrRU2StqiYL!{b05A{QI{ex}AyU z$2!Fu*UQWJZc4>kug9t=^|-^vP>yDg5STRYrGjAQWylc}XMLbVtM375ItElc#k7ph zoyF{rvU@03^uv*L7!hSJbH$sQ8CM1($02@|$r?ge;!e%t{I>nIMJkp&8;9%fPAp40 z%ePjKV62j`rz9h}ael~oLpa62#XPYE-ieQZ93<+iZH*zDVo8^+*flm&ARyEC&9HCu zKxpQ$lCS!0XVJbr>G|g}i>RrKs;^7x>DItD1M$BRa@xFJWl+-9(n!NByBJTtwU`Ld znkl;)Zgk;=%1$=!MSuz-70laj0iq;+(6`X@G9xfM-4-TI6=K0k4@O6PIg8~>Xo`;< zhz*UchPN+CLYI)}28tJ$ysb!tciu3^QIz&JI(Rc6-3Gd50U+=_&}-X8Ag&9*>Lq7k zN^H|JiS{o8UpulXhEwIfKAIaVr|K2tD)7$x&IoA6h6r(=9QLfKNHDqi1-kNc(7%Er z&QPXwize1l>P=<4$BuPHC5_!b%y!kk(b|SsN-UY^@nXkY@M6U~yMOqN%MxX3rJ?#ow4SSh(5AG|r&e?&1`q61g$6I}JFD200HugkgtQHfmjHt!9` zR!62H7R-z=Ce};q?*aGK7i>-``CpY^Nm3kfOt(Jrtgdw|26LwO>+hr(!`rqlSZmws z=`z6$Jx;NWpzCW(Zjx{#y|%G^9YRo-ZX3{pFMi)9RVDKG&B-A%WaBzNbGnejo|8?H~9~u%GQ}`f1JrFPFo$ z&^Y{H-9)cUwl$HOp11oF?c9zwdC~8Gt2F;Va^30mrzXh5dJ4tLymjYrWl$bTuHI() zqsK<)yRUyML^GBxGzc@Z8Q60Q%YHGettW+Yt`L*)@E&EZW<~6v?%sWA>%+FD(9Sx?(PgY)LBlFQEjs| zNbEl`fA{$(w#vl(6P-U5ap92{A~oZZFB<%4Z55tjMCDufMg$_a9vppb-V^xX_1N-m z=CL<&I7iU^eQADY&ZD5;c@-PvVKj};OwZ%jf)PVN*fX`QghDWFI}6YH*UjK$A{(IV zX8b#b3?PDv9Z!B>CGl&A#gnTZs{8%jiXDIe zu9DoB(K=zTa(Y^vLksiTrXECP<^J>A1H_pTib>_Jhac7mZ`iFff+m__5(A^X+ z9JAzwo*#9Z;cet8F0qW%wh+Gr$-H$BC)8$hF!g~Z=esGppmb&p>t1m z?a|D;{0PWDOX zPqLj$rR$Ay+GP$wb4Ia<9c4M4lifoQ^*=S#ih3D$ZsNk z+_fhO=L^TXt;qfdTm6=8-z&uxYQI;C7&##pJi0j}bLd09XQvl5O9oaHG<)Ih(^2r0 zN=Xcw(;98VjAg`~pBgaax?VPu3&`=~c(=Y;7wgy(-RSgn7wQF<){naIzp41f6C(j6<}gBWjmxi`FlI*cYq1%No|+p{ zBmD|h81&@+WbTy{GE0UDQM&4_Oud)0bA z6gh(^%caK7W^gg56dTo&T{&nJHbsX~_Q;AY;_cGqU0Pm_)E$>l%>$S@Nm@zc26?)U}9|5c3SU z2pY1f8|zonKf8LKuv!099R!#>S0Yk0aO6|Bo)i6z($wOMhQZGvOf@t4e4+T9{pHo0skRYBz~z?Ck!&G zYOCfvtn9h5N@|(^#aS_D0MJH82yF$2=2_j;$C^BTMKm^p7o0b4-RIGV_KYt6GKn=b`Kd*6$qQ#Y}EYYbTCB}-o0d;S7aDl(G{>f6*>|) zp%>v2SQ>?kY!~&&#iNdg9z!{CZ@#KAmKZ74kY zv&Zx}<6%gWkmDhgyN1%qI+!Uh@2id`9F`=N@VoNUYx6MH$L7*0Ebxz;iSNCbGM~B$ zkIk zlAaBbu53{b(_oGg8kd<#K}N;X={ptyJ941u8cenOfwAjkW$p!qskarURi7NzG%&ld zuy4P^>qtwEt{rp)1kk_7}FGhg^{a4a{K<%Fi?=KMA7T zFx|M`^wg|yt=f`4$?k#u!p?L6Er8*(L z!gzN9chWKIk~KBokMHuFyl$|Y_{!HPe5P!;TCY|u)X_J2DpuO>)fs>|T|` zRC~4fatK~eB0U0@=nnwll>WI9>ScNO&J(FLX9X(ttK@BF^eF<)!?Dam1TPYBY)0N9tUMnB>Q~)Xk zxrSZtrKqDkZL#nC9K=n#C4!?cdZwoC+hupX`g1J+A^k@ z?aShp^wersdaJ9C!8LP=0mR0J%qbOEW-dk=&rh?G#Imnf*H^xi}7CG;Yl00HSFkOWe0 ze82acbAP#G+;RVc>@l*R^{hSTXU$cnN;~|K-|2MMp5?!nF{{dE9dmGK|MTAN7w+P@ zpQErbV|s50kz%-AB=(Izvwh6+2d+;QoNrr@XEruQU`=6YTkz_M?zA{pALoBxzsYlZ zBx9$TzXJVi0qJnA=2%xp5o*f>obunnbI`))aSIAavIwvvoDDYmR1Uhc|+Y!Q#-6vYs*Je|5kD5+L_B57wBY*FAei0Jtq1&Zs$2%uUei(q}xmZ z&DOLoHhA(K-!gu*OFc}-Vtopa(>Xj(Nwtuq;;=KK0IROIF>y!WkFu53bMq~S6@gIE zDT|ek`VUc}>DUx*-ZHU2k9FG3h-(V>t4Ogyly$y1HgBuv7;96 zKtDiB`p}=lxmzK+mK_>=S4!%HVqQBRRIZ7<$xugT1vDDjmX^+Qc?c{i(PxL*-I}?m zAV|#Q@FHm9HuzW?%rct98rEeJycgKE{5-pq8^e@LJ$nb;4(>oTzb**^>5q}*v+tTN z)E(g2dRzzralTCBsEDJksgKe|*G_puXi>RwDBWtm_t|2~Q-r>&g4MZv5*3y5*9Jr% z@Bh%u_p1J=r;s?-=mMOdpmbMlL7;bS+AvMAhp`DecDfTlD^FT2;CR6}nT`aVE?nPP zqKw^930$&Czv1Dc-9K$Z7zt?^gAKRWXw9*u%(%1w_|2~3e@T~foO>`_m-+cybi%Ng zD{vh68|eU1-66H#+vB(&#zPtZ>La{RPxWVa?}rfxJ!E=!B#af7Y@W4LAs(BYaAkBqrEaAZ2lIujToU=Yt|*}p=;NQg9?udX(p4>u!* zV3FxZHuE(dF>ckCukv|z3uc30Z1Zt~W*@Vg>4aIQNe@*Im3E~*DPBCxC1%y{$C_o@e)+Q@ z0xna%SCj|$yJ>v9rOYYSC`?{DU4r8r$gF&;lN-L$$7mH0$UAKk`YSs*dxfW$`36LM zxZ!H`RcpcJY&xTZ?YAF?Gm21ji`HShi2YVv#ZN>4F_*ZQlGsP90IDKa3~c?6;uR)y z@_vq5ZA$wy3EID%6RR*)lT*0sEYH9;%TM3A|7DhODM6pwTizDNhx8g65mdcZ)qdb! zdRw&b;|+$C{kLh^m%FYyIfSTPWVplzE;&3-CkZ|wN`m;%+4Z%)W%}L8a@gP0{a&k> z93P5UBkp19o^N|<7PYrI+7k?LxU$r&=XX>>VN8XbYL_%LqF4^i=vSd0>qj|^P0JKl z_z&#Hy{1`N2f&59o|_|K(EvfS#@M6}OMpkQ+LmuCV(ym@hW)g|NX^FvH?wavu*{x& z-h@WwnxH2AxQCjGnwHrOPnGXpdE_dqbBOxOXrc7f~Rz0j^?hzA%VtR zDyOXLlQe@(Tr~>RMF=n9=5grqQF=Syr@fZ7SMvA!|8&beFS~t>UNQ2;d3JPV1l($J zM8E5WHPD2(aaI!%appaxFSL&wp7VnV2*}zWV-orWclyR(nq9YP-~v^l$+mdg@lhpi z>)^5FBz>$8TAn>UhIQevtAq`rRc!XscJA<7n?72MoDFmRoVwScXeRki0oYS62+~Zx z-C~Ovh-;o?zL+@}Cahq#+#s2l?h~{#0Cg9P{TR97cO%H7N4e*9vocR*s<-QYW*G6e z2;gwWJW5|&T@uk#r#^YlH%LH5Xwf6tDq>;4@RBR#q%yEq5hzOL7!&RJhjz76z zgtDXl384D2CF`?159&FPUv63cGYx56Wn8?)`y$YBji<14_5+rWjG;7B#bNVny}JI_ zKmUCOn(fGlf=%_;G#&IymXHQg?Krw;^5sW?p6Y9r&bz!0v369aDehvzVGBKSRBQ9z zX}A3xDa&GMKO$?xJ1O!q@dqm&d)* zgX>`BH&GRe$_?{G^^!}p%VI_E<}T_WyUper)KZS}QNqMp0AFDhIf3^O-L8jRxw+hW zgw^`I${7r@Dg(7^_KcLIv02HJd_zs^4vH!=P`8vE`%`PRIGo$J7i}%<1j+?xULD1H z58+?`y{ZCV*X7N!PucWaM@ZzQW=iq#l{5JyZAOcqV=|NSbf#M`oa5ZgGglg2c1fu2 za`Q;MFnAN_d=r1xff-uX756b@?(3sCL^g*A$-KS0)86Etl`zZoDn6syzACPaGNp!R zfD<~cdVsKTSQCBvoREQ(A7P}s(kg&;ao@eicXdE>=yPVXNPLve1qT%B(2)?q?$yA>|5uCZ~lDV)(c#zo?@7 za4>;g$sJ8CfRSA!5OCT}Wya_^G=HBGi)(E4_izTQO4R8HfA8z(C?F?^%d)S@%f%JEE@e9Tj{y~w+2 ze^9!<1%j%iNv3EllO@?29 z&3RaYxun&`sPQNG?>&dW3o+^e)fz@400=ArpF#!E0;Lw ze$`Ka%K|+s>JV7;0(on>-7P6~(c(im4Uw91rXf?`1!*B z7+?L2gUQm*Vdj16OrZCeZfRe4+%_d?pb=a3jCyT2)4#3mbk#IN`XDh;_Qw}m9J-qO z@R>`DQPPmly^XlsV&iUnw|SSS((I1=Zin06s55E&ti|pX^OO*M|N@Ro&|Rz122bdlUM+nbDB+%=U#gq z_4)u;P&@~3V!G<1xakMG4b4w_HLnXeU&ncOV4B)P&&Cqi^s5wy+0jd18b*%uhiaml1C=Y_pc3CT8=j&c<|*E=ZC zBc3Wmma|#mhQ`1HNyL+U&0R^7~pE=QZrunaj zTvw$-W5D36n$>-eHp?bGAa|OuK(Ksb2+z>e=kQQeB3Y^`d>f*$H`BE*Hg58ygOhtr zN!?SRGjJK36G*H$Ng7++dyl%f`&SECxS&e5)2$=zPEq-q%>4ZNk`O-tJ*M+4m-NjF zFAeM3Wuhs)%4DCOF(Ve;k~&MVM*)|n%mbuKodG=#ma8>a;- z!$wdx`3m)zGP4r>4>eM1H;cYv5%MaSC(4cCO?jSE_2G^?Zn#mC<<-<$|Kn3(4#J2? zAp~+Ta&7i|cOshaJ@XcpyFBaaFfr`<@RIh5TR$TAPTV!J$T}qw6P^f9Z7&GHj>&ow zGEQNHA&#C(=9qX+;^9!yX3u8%r9GTp*Nr!@hPh;(@8ivD2AGM41l*LMioNN?a>EDyI^{(QlHpa@(;n~XZbupVP zHyHfKAyCg-S8)18ptTP9y5e5P%jz~-Hqj{}asF?IYjVX^_?;F~Q%?7E*A_CE2(BH? zeB{#g{ii2i)Hm{t@X3$&T*|z4&$+fEcDW#c(Sl=D=-4*nPdumTJ?%Mpl?;ioqb|Yn zZKP%d#BY}67E0Vtd~$k3xMZeo`PYHqOO9*Rje~AKPWT{gcL#vnUPm4{d_Fau7BQLX zwwYh&7Sgj}#ecBU?GB0R1CIp9td#7Z7>(C$i_VQoGL6w63Wd!vX+p6e2$Uqm?S>YyF$ zWisc8I*d(7R_E*Ow(jnys_g!8jZ*Yj4|Guud5y#wUT7Sg-V3`sWau5gP*T=jqd=08 zjda0a&>?><`u&#CIlk}LzM4jQ34wb&I0N>hH#5*moB_4RUc_)-O?Y$q9N!+2RfPkE1)OdylQbUggN9NwW^tCU7~ z$u~yOkVHH-XNr~2`*`MCh4Ad;`*oh>$ghsAE@57cD2g@94TM%-o zsdPnOlGmEr#{`s1^aSaO*xK#9wDq==B_^-J*yFH5o}}cfg;BGhy60j0W%5R~t+U!q z30W~iu$Fx)ycrLtS3%GYclCwB?@LRz9-E7}IC({X&#lNW&qr-HjwBH*JzK|{4C+kf zUz%{r8K;C+0ky2h@3uq}V){je;ZdIE;ex&ruW>mQ-TXmVH(Z2=P@Y)wob0hIly$Ck zKZ(dT(?4hEZLu)tQ{F_%GxH=`QadrGIh0Cl$+b6Y;P*8d7gVWrsCsB5CAftav57#d zv39YDdWA4X%XuKSw@%MVM}PpYQ0UwtqcCf>Y=7U*!kYFvzN0Q6o45ly=_;p;6o z-aS9!1*fb`&uj~YWBpthhs@5*8K*S#DTt&1$^p`o$sn&DEsU4v)>46?yV4Pk7firC zBBn$DL4^vu^|mlGiJg4qV0npD%5nhiWZz`19)f9LUndK+P9D%s*}F&$diYKnZC=hIBy!exhhF{wNrkmmo&2k5k(; zR?t*0t%I$64Z3R9vVjX{a8%Ep64Wo}iDU~k{5JL0X0H1263%;PptX8aFSj zUz{gu&%{b-tAK1ArjT|tW1n3%(Rabsw;<3Q51^kaOt@SrC)0SI++}TVwwbIi({d5kkwf=g+&rWcxEQ(; zJX9^!`|c)@w;l#InnBr`vkWB#w%;%Tn;Wlp!*XZ?EpXVziql&2$W96W;WO6Qk(mZ7 zcX=~+4-d-HM@9^v3v}NSPtm-=kcY{0C&IR=w+LfKBQwo-^0(ICj_r+SLF&Vf@(**4O2(v|xwGmOngO%j)adEEaO2*l!$2%L{v^ zSRc<)l?`Igo{;&eE0GneSQ zP#pk*^7k1jQlQGn@By)r_@}fzxPbah02{Y7ynR+YAI)HqeSG7z&yC*P$_&CcLRJhME02DuX|+? zjamRLiw&ywiuSJ5)elX_i-Bc&jT$W7!wPG6iLKgHjn14?;d1iQD5U~s`vRPGC}#gh zj`xmAVNuYU)+s+NT&R{*jXK5gL%hBJg3zz^|0*Z?JBJ>y0^ScfNjsS`HDS`^L%S} z@sywJK~^!r+9T+}Tfr?!X?uN#-#p$JN>L9Q7KwEbO-M*@XTWIt^dWzKlt$7v4XyDN zkA!>|MqLpitwSE3*=2AqK_`r)$7{Q;p2-EyEUJB2RI?kxhr5^a?ol7Nyt~dD(5@N1 zv;Ty)m$$%U?!Wx%v2~#%*NI_`ts^;eb|20joCZg^EuWqZ-`KHR7hh9VChH;-?-`~H zyFt^>JWbzv)+Z9Kw(CD1G30n+@#Vbp_7fZO{*6W$CyRLov^3m`)84hUE3Vjo<#!jW zXy&aq69#c_K@YTe?9htfY$#zuEH|#>YV!s3FcRDDg(+643jmfHX)_J}kDHw-; zIw*3A<Eho#7?;Kq^e2g<1nS+>?Y6VW+dhC^x;O<)6X)F}qnehKEKQW-P3Y zc+ySHFTV-!SM~SYRaqX8Hf_48_QAS+H<_V7ow0EgaFK%|7ddr z`Mm@K*|B0(xxQM0y9TktG*>LFT%Mc7gIkU*wx^lE9G_oaE>7n`!&Nj)s_B;9VV%I#Ec+c3} zuj`jd2qRv)8zAAkXGr+YseZ;%zVXHMek$K>AZ|u_4T7!NeCb+Q!-JTrtT-vbGjh$EB$FDl@7By{VZ1MlFW01^xQZzcw- zZ|Wi@XH40!UjLZ)dhObK^hdKSL4s4od^dO9MV0`zJ$m*9!;p@dxhvZ z@m3?-m=|p}Hn-!_zP^v0Lc#(Rzj?7hW|elIlwdyxr|MHb>f)NosGz_2AnhlAwahjS z&YA@E7r=Vg@YEqQAHR>3nBY0xP1&lvM^--M;$!)150UKn3AoJ>Y57mfQ@&KKRHc!V zy0e^4dr(W`hcf~V22?xnvSkqXrlm>&e%;4#nd^i8Q#*Y?RMfd1QbZi6T`+-wh z+vLcG1!_->LlF8I@8N7Vw1M6UUYfU6WBCm?hQFYG%$BT`Yr%|TxKQc_P zjtd+RXH%>gw}~To8GFO?$WW}SnEum#S=6RUtdCK7TywH;+(^j7V?ZwUyA}41T3O4< z&g12!W*)*I^SpBf;OoBC(`P6neVe8&3+AB%!OaX^>}$n#(+u-D@p@w!U|K3JZ| zV4Eu(zx6wAaJq;R7Mr~NMF6_Tja(pX`44tFm+r&12du!EAB zAX=4ImAjG#Fo*2Mg0>bAR`_xAQNbx?|L~ml6pEHvSsc=5&3v0M9P*oJ_%GoCC>;-Y zuFC5n`}hJx)+cmQ&zcEIeZ+0NKXJOy61`%&b*WX^z1-Mlc~#k`zpyg~Tz6f?CI55x z{u9<&aR?eMiJ)$QAEwcyGg`~KuD4_@6*#hHys$5tYTJaJZD3j+vEQs8usgyL4po{Q zpvk*~lGQWg4wR$Y)1yAdSfR+@K7!NhnGNd(`X&4p?ILufZq#(#(vW$N_Tht04v{etPdF+XLKQqXWzHV3EUv4zmRto8CI zO`&{+RiMK;FMR%tAN|ik&vK!w56+z`l@__|QH2sUv9_HKs&?!U00+lCLBN1n;pbMX zu|X!%f9oRUTb^zHB==IQ$~fdQfw*yc-kHO}iLIk)oSvNxp|Y59tz+kCS=;3alh5*< zVVa*3;`o2EEB60kS8lT{WYIjAqq|yLF_N?x=>K!U z+Bj=5)ep_N5T+&4?3tH&SYrG?!4+y4+W8*Z9ec8=9TxowIbAjpCWvdx?ccc=w7nFu z79aukOWA`8)WPR27>3-w6u4UMq5se+TButs>83c$a6tAsy!Owk>(}YiNcUh4M3X4x z)6X@T5)PLHrlWKv#L-WwCeq|0&hC8{_HPNHp1tC`hxf1Puh}ZO0Byzx6&sG}=?8Av zq=c~VxRyA)RqdNM7|!6fx4S)FKDM`^z^Ej3WWF1?9Ke_#R)jQoRtGEg>-3^;(q|{T z^Y)08ZgcJZp@>C|_;@#U90^9SeONrZyU5L6zB`_#?MgX(3&=Q>R{=Y0`q|u=i6iiO1+Q;D$LH#o06C8*b#dbJ=9Y(QklYHB^p9rgv4Uw`_C#8)U&$hb6+&P- z{RH^u-}#qc1K-UcgvV3phXJqQ#Z3wQ?}E(p-ZEj3uDWq-)sKI))kqII>Pg1nxKcw@ z*&r2H86MhZ#*Va_DO`$|h+E;kSr+@#nR;Ko$S`^pD5_0~W6#Sm;<`%GEqX7=N&|&(3*l!v`D7O(Ib43l zqcc@rcyhn3M6|B_PXJg7WAMpl58fzRA$CKB$s-$9`usyD54dXLj}j!UM`C)(1>8Kp zZWIC3^L1O~$@BQ-+jY;Y_td!glqnbTw;_$VfP*?nk?(zQ62zm*dh^8CG;)Go|5p}3 zb%^9WRMu%2af5f(slwggs9ePH{)x5QW-dJ6;E*+MT0>t=&ENNp&wN_8^cjOkocf_`Y!+2hUj+!p8uT$}PAT44ypl)%HWToT2)Gxu zW3%8D>E_xW^3(sZG-->WO9X?2wC|A+Cu3Cgm|-B8lYGpuNMBIF7lkP|592v8js2c0fY4LKq) zuEqS~rbV*8ppWfJJk>Wsl?<#f^_z273yB$iE}u+pbbOefVM*=ksydvi-xJ>N5TDdi zX?@|VBnUn$02An;qZ!?*gr6@6goc$p{&@Jf3+kfkX9;*Fp@Y8!sodydDPXs`U*Ra+ zy3;aQzycDk51fCO{{Hsm5EMLJdA4=qhviiH*Pw|Ra~e~EzI6mdLR4_$33!F!9fX_q zL>YStw(sunvKaZGXmhst6qbYf20;BCzH6Y{rFXhUPH7s}d1Hy^k6G{xJAJo$Xqh~E zve}$4i&zU&bAG=mwe?JKi!j0zuvi7m`O|%{^^{OR>c?j@xJRRGY7!nBQ?_BI3pLAF zvPR>$t;b#EZf=F6sHto!7P6utWkz{eQhOt(pn}epG05bK?_pAoFR^~{=udwMDF*IU zQ$p#^3{X%UuiU$6=@_|E?8&lxy!m5CPf=`28zdv~k% zAbHHFM{31r{eYqWp&x^m+yXXnYfJ8FW3D-(>#msiJP)ou4Oi8?Sq=|>os?Rm)U$(Q zT_0`fOt9KAB~XcDn*}Qi{^m8aAV(2pX||@6TjBh_=kqW0C^~G(P3>lBE|Ow-DZ@!( zwY~j-eRNiKOBoDzCQY7od08H0P^9T#bNBI9q8JZubvIwHH5v8$wESVY=|&9KxHojg zyJp4%+WPiR;n$AXYHz&}%T&1lUQC%^UiI(jgVBp$UQ9Qt4cs`+kg9XMi{=XYlSnwK zP-+9nfEdi?Z{FDJ1?m%QB^S#_Cduu8GM~KxZ8-q`c7rfOd>FkgKiuHs zFSP*PdrcwYva|9e|Kbq&F^SAuC3i^ggQ1}yVl{nn zVCg2a)!1g&QK=bzZ7af{-@&)vXnwy=h|+Ms&yoZ;sMS|Jg{|*9kQI8|yG?M&<}900 zt*tFt@Je{^hnX6B_DHZd`1R3XDhQki^HAgQfleQ^y0EXmQpqi~1?y{W3txP`pbzuV zD{6Duemf*XyzvOS3`ed1kT>LfLoW?a&k8%?p^eQ@1IdCPB00z4{Wk_2&lr0!ztRnL_=lql&kTk2 zj?#Ssx+`(9gNvasdqg2^+Sgby_vC>@x4A?Peiwp1WHZBNO6raxOoM0?9Ialgi0}7< z*YYII@Dn9S)n#V5hoR$>UQU(p8O|+gEizZc#o# z8fZ)Us+D!7&(M@oPz9GgU$FFS^*z%~Ps0e$&V}&=aXxa6MXz^A zYu^UiJrKunW=dt5GqtHZzZC994KZB`DqTjmfjF9FWJ@N7+g{tm!9szg`P3bqf+DHU zzF&96{JiC03RX?e2h@9_%leN)Etb~Ke`<^)Wq53$wMcrv<^{|~iX)=8O2vGaML-Jk z8I<k)V`3 zOBZu=W@ojIxzJCL^e^FI!c{|-g>=fvB8e%rd7mC8zlHVFTaq>n!3`)cZ;I}0{-QQ^ zAHOTDd^+xVT}@@@)eJewR0m(18Jp?+iym9UngRwyvoAg>$T*9AE9~;f87_Pr1S z&6cg^k^dM){-RF`-xF6?!1Xl2{2!*t;x&C4N);{pVe2YnTt7dIih1*Q^g5*lgZ(RI zP(`lz{-khcDw0A_&3`>xBpLw@dX?AIL<2e)b`u&4LL;+`4{9eq=nt4&`Ju|LQyhCJ zy^dtw9m-hZ8 zJMP)usRnTEt9fee@Ev8Eih-BxlcT>F>{S*#<*lS{rXdTw&qy%5GupI z(xjvV=!IChuBMypJP}HaiBUtA>+>rJ{_X(8ZE=F+uMN@u!&Bql{u?@IO`T7N{6Ixt z{5<3yQMX4{iD+xm^t*~iY-h1YRO61qCas2NG_Wh0A*kC zb<`8W>$>O5Px{m7q}^ZvGH%{Jj2_5snlC&yV^=dPh9A9daK4*&{CW8LdjRO&nJtR> zHIrSqw$z^^vTT|g^MZH7cD98rl5do9HlMwxj{D13(-nD9=6`c9ao1@%3 z>o{-L{&xP9e!@gB5g^1-E6@|k+WQP3tXXM#b}|rRUoukRP1lhv`0fvFg^Gj1djdMT zHvkYU2A~`GM0oM!R@+K9%k8lA+a+vI%lZkM^%a{oZQc}}mI5}oc1h==_Xru?$ukq* zyWipBHCX6_0~+1(UgaO`3-J8OdR?T`sdKV z@_3AC>_7Z6^#ELtk)fhy+WV;yvG0zfhSh`7?`4DdCI=NrovnJv8iiQ@=vUj~$kHJy zJLDvpx-@;BH0b1>GuH@PDKQDfjip6Fcy54}zTmy<97(>9!RGEWcQxXW`cLdP_j{us z#GvVxv9xu)ty#KK)j@I7AEk;x5e)}>{fq47VjUv=d>5Sw8%fw|>P*_vyogfMRoIj` za>l&J+Oa1pIqj_`acOD`V501i&EIZkXMFD+7-QWb+{&0L2tH2u-C ziEfw4DYvNt&s)@b^Lx3a2zy@KP z6U|lMFkE@%f*io11V}lF9mwxqjF@}qaV1ug`vaPO@}}8?P*dc(S4m^z9t(mCO`ATY z#e9i$$IJDByTD!fp^o9{IZyV=iSl|fFY*|Pef=Jf}~ zgju!^NGl8b7u3j-eSVi_SdRl^jTA^D(*Kfc<)9w&ZJrN%=ZEMblCq=oDS1YT>oze@ zz>M(L*PF9<{&S`H(0Gfqb}7Bb6gmYGbXBuOBL^Y6BMzW}AeOy8aFOS%yuvCjYFU`e z{)^NvK;+%^7GUG6QkgX2pu?9!w$cS2{_1~U1l7-XUwbUQ`w3|55in)P4$uK3O&%kj zTpNG395T;db~bO8U((j^z@z=k_!Hf`QNmJhj4U79!Fvm)SM9%f&~44xM0l<)W^>&J z<+@Rfq~@m$`t4QHWmL7CW>*ENdMg8`rOs%e)sux3z|h5fUu~!+&%#Hgp8tZj{6R zDW6?j?hg>0KOv=*xOxZS<55r3>s^O#QeKUBxbc;LtL0KLkhuKN(s6;s?_KPlf@aFB z6cxomZEpR0VBxJ7YHL2>{~&h1sP!184ylQzr+fJ&&WSR?>UBw;x`f?t%VOaT%(}G;xiBh$?R?#uQ{1bfk^|wGTl?Le z?xM3U{%c}yOerH$GJlZ`nv|7f9O4r&xh4r1PK!$XfVuhiD4*DGIMHE!~Hd9}tr zDB69$Ry0o)&c>74b5{BvD!3X*aua1J20>3-fsyPzxU63Gv62}7G zXz^>j3Cjt(m=y6)R#u`j^+g57=-4>i+mb*BMo?uVX;fg;pqZGhAh(%^c-QB4FTpXV@e+Pb- za9y8=IBGVbt4>5}RcE=^=-$^ouM;UIv|hdl^-AaJJ=_n_`bsRlxx%2$o;Oyx7%)h4 zP6f%zn}o^=8l5plI=IbGvI^2TmhrjDdw%1YfXinf9znfBrd#4p-1v_+aqCx$N+VfT zauIiA=IcjOuZA)Q%#BjxkbINyvTDKTn4mX6bcmPR`I@=geOrNR#@b7Np4brUDyC!p zcPPvLR}c2gimH@h!kP-QRGcG>+%n6$t4o|G44LWWYZR`X>DyoSWxjZJ`n*!K#h~Sr z&+o9$%=_1XRCPhUqhR|jACbk=r@d`y?qMt}S72*cU;hW6PKaokMyf>1#jPmt?Cp{6C2LoRE7=ZkikjyvBXlb>Vy z0~wnhz3W>$uF8Z&{;>lm4XI!&KDQaBEfQmy6WS-6*Pjq;3-E2?GDRlXY8RU-e#a5o zoV(2XnWXZXyOs&RC`+?LIsz;%CJe2H?;~nWV@!Rw?ngKUl=miE(Lm~ie^xTn^({m? zr`cHn7R-uoR~%7e{slgnm4m=D#d3UM8D-ZW{V+TQ@tdAT-b7X;6k&59l%CBLglhNM zi`_N0A#k$8**cz;1nSb8Xa?u&T}+lKQxU1c6a*DWIb4GW_Ty*vh_v%+P^cadQ4zPT zF#PCUYLn(1xLlW?xdyMGVk_u?SeKIE;VfIHM8&0z`KHkIk`tc&y6Uq9sXP{A>J>vS z=b6WQfrV`;jh` zCKN7>BWz4dLerw;?`D+sWz+!AtEW+46WESstiy&KoO1FsEZ2hwbFe_MZ~x zFuM0G8C*ucYgA#^=6<;wm@QV+r@D9!!TJ>?1tk#4D{%cU5fIk&h&_R4azU{ zt#G8?;dYtA_RP~#0&|GTuZni;LGWK=%vb%bx4dQ)eP=(DpP=_0-fI7ucF0Lc$Ix{q zqEhXW=fJlQlJ4x~&(^m>MuP1a_N6oSSTr~fW#gEq0E8hG?icQNfZ4>Z-NWH1Ig*J; zm|gI4z~=t=t#)O=>QLs5b#3nrwP!}TZnRLc+bm6;gAdhpzLajQ15rHjG6R>>xQ`Bk z4&+JhHg&G0K7vrv;YR_gUl(jmjq)YBCYk!ErrEhiR$A_n?OtN<1hjW>kQgR@HOry# zpcOuU3QXsWIU>fHP(IS-)X;)iRV_~6!jclKjjduN*BvM>)ZZ>hQWcQY;QM#lw%deJ z4{{6JC)e(I@5I`BjZ!{$+Woa`RCK3uPGU3XPw;ZG&n{4|aMrr)i$;Zv`tn8Hc0|MS zJ{X@qfSjExjK1lDOXAN64AcHzmr3JpY5Qb*FEhv@!V~CU0ejsJO`QY%A z7{o16DgWOu(qH2HxO}y>!-$eoQhzjfl~KpK+1k6#Z&%rBVNPd`n{`&SnBU>NEHBsd z(mJzn3`I%xL1M1=`qpWr`I!|NNVAX&UaYz-K`6#1?A7Ih@kyC19>UI+|u4wl4 z1W|1?a`=Z3gS61K+t+S>zWMRu1IDlF>eoger_wQc@jQ?G`0=I2_`SECNCAa!ddUVx z)~|nlyRPtVW<;y>dXdnh`*~5ECn6$0iJ2aQ>0 zNy`y^aHJW~_!h|MsrdU3?7gW`MakdfO}qvBuHO9B(~dGHEO&5wXYdH@AFKgpm;}|l z&Gqh=mL$ch31c{(O?$;hBHERdPRk1je8>;X5GYAOA0HzIc3kcH7!&&aBG^x5a?&z8 zE2ePXU#@arquWDLB(pRl>4k}x=|-A{B==;IyN!Z?9lq<^YwhVM<{OPF2i*RR@f-Fu zDzs4UM#3X?0mE!jT^i+4j=8Q@=1SY#wxavYYzfVkS(cPo#*RqKF)QPTU`38PbW3^B z4oa7*2|eY0lzxrU6;G-uu}@#HdAEYHdXP=sY+ywGuA+nIT-h-6NZ0*6vKCA%^io!4 zT?!pMyYW*gAWoB0_1>v(?Dh~xWxlAX0~;cNJVYMjxwm%iXR-9)reO;Ib{(XP8~iPD zumWogRa#SdPI%EfTaF;f-l$KaMnW*J;%Z6}|LcnLMdbZQl&rr6nqP{)wnPkQmJ8+0=vZ#J(8$W{ucNe%8h`#uJXTXItZa?jXKMf|`z^mFW^8#@u`jzhwD0ao zrik@oGj|S4tI1M$<>Feywl2qtkIo-#`dt$9$0mP^c$=5mB(-0nr+z26fq_V_?~H0a zaGyIz_FKrH>4vKcrts6Ezc0pVQQvomYqU_Mx(`3!HW`JE8(P^Zf{xOp?hCzy?=WV0 zPukJE15F7l|BKMa6S_j(RebQZ-1b(S?Gp?TVEhZ-ldIUJd9iODU%*O6N za|7DXpdzUQ4ym$J1jba5!**SyOGxY5qv$E{9tTkB6bT&O zu%Ei1s;t+73IFHKv?Ml5{2zbI{5|5s72oNId12<1F^`)=mRBgfX{T>L`x-b!U3ye4 z77CI?%9Tox0d^Qq(=|mZ&i567T{h%$NSQ`&0%oT*%Mab!<^U1#s{C=SX`S#1PHsWd z9?xK3?@Mm+jtc%}oI--6~nTV|B@Hplx`nxu1 z4P2bAp$nV;e()n@A#@M%SQTR*_bOIwFg}(3p}MyPx>iQb=3>Fq`$lTV@Yd2*WL-(g^$WR9*`n5=gE#`U2?rny|4_B~4@fm2z2@A0s-lU}d zGrVJZUs;P7Bbokrj9Z5*RIfG=%=HZ_1qVpo{5heS!7Lba|KW}|Ln7RK=%Yr|1rx}b zT2$`bub1(X&$vH5H6kmYzdW}g5d*y{9?1mXd2i%99`I$V;EXqmD@ix&rH7D*(!p-b zdc4m4A?X>e?}=Z)!&{2@^`z7$#nzFoOsSBSeOA4_Z;`*;4hw*reBOgxV$1a|rtRIo z*F7T^NktCOBQ2?ySj2|6nc0@bHQce0KRvg{w^(JP<6AFJEKq^+)qet1-(8bR*_+`yEB4l1*ro?W7e~D5+EZsZjofjBN@4#ag z{*pk-ah&ni*lCmwW^SytPYJ$p_GeRfLlgEuC1_03+EuOOO0C|~#RfWE-!^m{kKEiP5YZ&5Gqb9EF{f-&r{FRt+H6ioZenn!} z*#Q+c(#I8tzI#Q&6p;~HkO^7m^;8X1jG@nufYoR@6m06v(_B87JDo$E{e~bQ^`n!A z($2Kw>6q`5``j$D4|*Lgay>x@fhFeDKZMuf0E>rs(i8@v^tlZTfq6Swz5D@L#bU!= z9GKo}Uy=W`e@S>_%a3}cHt38il@>`cq#d1|bN|s)j%?C>4TQ#@g~(ge74eK$&mq-@ z8=U=?{&9)MVb?COKE?SHT6q6xJ`>mTB`n`%;-8UBiF=+TRjj$#h+hC(=@JN+$Ljbn zl~#f^c!w1*Eo*m z#?2x_kRM}cxg;PXleM~tbcHQwJzN56u#uMRWI9`>eFvp+Vqqy7a8s~K^0M=C#IvqFk1t;XX$hF9&es#z*>iRiF|9V>JBkzju%qkBhwP}eT4i8ws zn87ZoZ+TW}QZBeYy6FOBxpG$+p|Y8~NA>^+em#VqQwZw1OWRZOf$7czmi6rsz2@1( zS4-1j9+RQpH~y03C8&zY@wlH$WMh)En<(=HQaOypWY`@DR%sD>?-V1T zk5I8IJ}>a&wJqmlVJ^~t zDf@3U3Av(JMGhgWG`(LoX?f>hE^Gg3v=7s|G%*EHc6U-t&vy!YPu!J!X6c^zEAlu% z{$Z31?(O%Ei{t*6WgY;FVzG~l`l1}gBuQ5bv-$6Sl@92zYiG8?{o#TzN%VpDP$)*O zd{>_!3|+bt{b~+w!2IFya7|HNNH`WqXPp-xn0~4 zaCgZYnJ~R=;}1XF7g)Qj_SSGpkgRGdZ(4&vA1;GYrEVHp4b{xb^+r_xS1<#3K7Qd}*@J74=R@*W3`#zMYrS zaNONC>7B-1TpMZPQYiW0?lnk1POU9ue;so(iLr>IlVMwVE9$4gtMB~yivN8$J~ncA z9VEkANZ>VvbtoMtLAc|W>#jcX%dC3XN~sa(%Zkw*U(*p2H`Sx{=vLj++WO+x=P8N% zO79?0z-Dz{o=a3(>e0X4*sM92WnH>5byiOkub`{x>kH;Y+1iUgJQ`WqN4c4GMi0xD zs}33aGCM1{@|B`MUTZUgA7uDHaDy*K%9{RKFm-y-n=~qsesoC>w(THSq9ePl563UM zWJ{>!z``hT)9p{c;wS~&Ad~A(Ly#49Z;%lj6_2M#ZXA}=39wLJ_ zoNO!Z6-t`j!4vC$6Q9i8czGyYNOC`2L=KT7U!w*}`K+84 z(9wl}nSk4VDY`JpR=qJ5JbjDk02pi8q!=f9P<6L@^P!Y39wqx$+Y0YB$oPO;qAhuT zE0kXDjMM`L?~Wa}x<5vY(ElSSvZ4tRg(l z*gExjd~>IM^E$sLNb#A#m3MH-$EqJ^o$LF8rl=&wmGENuH`6}oV`Hbc!!*+t-)+Vl zQ@2;+6h`N2(DaR=Z(JgJ0UXEh@h(2XwZ{HK&?L**O_qmGCXBRWo;=Wdhfe$YHFysn zEv-zp(0|`J@q)miy+SSlbw~;RIZ~S=bK;_h*YcrLY#Y@0lk9ug5c5x)hNdhQNBSdkKuQ#%erNYZ#^^?b(t21<2%?Y=(z@euFOoMt-&v=A$#I#lz_ z`<8-$-L^P{=qO_)bPk72Y>`?a>NoBANXAXZ_9;5Bt4bT}x?NCb>ayEIVzWRw^u>-~ zoK{+6?z118r-Y?a6SbSxOTN|Lh8F;1#*N_O>J|_e{jP@`es`K7o0Lh};qo?RT$pcIJglh#aQ2?-c7>h^MnsNA*|9$p;(XT|m>PtFC(9O$V?m*ZZW(F_gmUujN$Rl^LJ~~I zOkw|1qt5!9S1((iEpiJy&YOUFbwF06zAkLx{GlH0gv*4sj(rsq^HMHL5Q1~g-)8SX z4x*53?@kjwrzZ%swV^u3HRSu2MdZu#Ppy6c&RV)6LJXr;RCuAbMiI2S%Z>+il3 zJYEgBYay;0n!kIoW$L;P)SVQbsH}f%5Cw_$cE1;soA;yMt^_@*RCzB^FKx^*fgZT8 zy>!z&TJ4-mdxb|)pV{@zeZGrI9l+m(2HInog>^bNDC~XCxZoHqsudhYOJOgF*vQJd z;qP-Hvk!sbP)U$Z8c5gYQqVs3d^xzD{uMKZ~1o z_WrSh)jDguu~gzwIf}7SS%B{C`vk{KH)6+CFb^uP<-_#N`P>**dC%!^^wqsPw@@Vw z-BX9+ZySVHczm#Ecsd$24NoKLGBQ}ivcv%fy4t2jeV{rC4?~w|&F*v=U#I`iMhfF>2*cj0-Dw`60BdT?1yW->3j);a5Ho^8| zY2uk3jmUH7&U>BNzZ;3r8PRg`NRHbw_J;^!r`av^27^B+Rh{%nV0HXo_=mR@e)*-x z$s#~DO6uTX=5owOjl`>z)x)KL2TpG()3Hh#RES(3Q6x8S=1XqAv0?+^aicX^BUepG zA-;6eLznh@<0UmV>5129r>v-)ZPLMivyX$4p251Dr&*s*bK@26pU>-9@%w81!$2Tx z=VYGb6(fLVhe^MD;8=ddNr#L)&Ztz8O?2K$s99%Gd6=OgJ~aAY`XfY3L;nT;$DQ_d zQZf?_BzXrS1hKyMe51S(3ZgHlt7`?R2Fsfvfp59&`KA{GyD|~M5Ijb+?}UW1NTur4 zl$A_{FsNORC8nzuy1&%QZxrsQcuPAp0)t3VcMN(`%t&q|Y^4c~m5+Vy{Oe-}q8;(u zHYYD<&&Kla=5YN;rDkVyRI>&^@q}+jN_`Aji+vuRQ(63fssfj2;<(?0-IJKf+jfXn^>~zSiK1#e-eV`E!t6U8}~4dp}I9n1(Mz# z>=@HO;PCWyXRh%7*_X$S9N<-81C1mC-z&JzgO$iRfkK$x+JuWxa`MYmD8ZjE9_$NS zZo+vXm>OVb$b|lE)~xjFRwZ@x@peXP6*g*4G?!%RzHI*KHQpMs=2e2Mg|#0FGc5d# zxZO1OjXw>hC+&UV-=B&imX4I457(Yh^WCmSJysc-D?Q}LTIw*b3c^Fs8YvykTb-L7 zTWEEy8nY#rrCL&QHasNW_IXsuY1GyI@#i+G>}-(AXzK_951T-}c2UV0ouBs_mxOP8 z{wpp9Fe=Dz06qF(6?>Z6o_5jG=OW5w=M&Bu&v^_yPV^~T$jz`?36m6yr?$d%nMJyw z^PA9ZDVf!}1b04AwJPlBXxvSKAEclOiO0MRV#*S=v|Zn$jp@X8WvC{`M0YjS9ef%4TySZJ-$mx)T)0Lb%)JX70(`fa;H9v@l~?$o%UDN^;LNvi zS6S0iJWiDtE7mgr!Zk~&$kptnH$87nRvs#oRn_5Z9Lvql=DPOfu_zdED@haT9oW7u zJ={2)41Pq`>d$@#4zAW)`5#!h~7put%}Q5mW?NdeC>d#zd`_=EqcK zAGmlxU$B3o2>8$un*{Apz$QBS8kHk^RB{5fw@kpjAgd%d?&7o5vN8?;RS}xW`fsfSqmpANIE=g`dyBhVv zOqKrSLt1|>N$g~K|Kz&ZdX#7EI<@nJn;hL+SW{k7=DY1uFz@3&ObtNnMy7JgEAXDy zJ8Ox{;u0+Y)E7R5bwX;sJ7c( zr!OObvi&zeWjz0m9I+%9 zf>JrPM;8}_jjDY2@RIV+<9!&Ryvpuk${qYzrIOO+{7wBd_VR+EtR25nwgsl)_xv@6l5{;>H z7_jaN4z@)KLQ=1#C^T3dPP5b(kM9(!I&A{(fjF-)>Ww!shb6{J_cHzb_eO?>0u6K9 zA~&?PaC*e4182o#w%{qgt?bh9E{|^des7HSa>3n<8DYq9S(MS-UCz z2>&bHpjWS?Ld>2Gm^azUM)4!BC;harS@aEK%H^ec2XPkz*ru&N9`bj7@jqLW=XvSs z?SBOXNXhqH2T7JctLlpy*7E7^ihzcOHd*g29jmQBCV2R)T6E8Lcxq#NQ%Pfy#;mexQ4n8g%&E*8`otE#4#1kD8+ zM;psq|4WSMpW+E|R!hf^_oi1~L0%hKGAoM~K&vJ60PigQc<6b&fE{`;+Z4u zKOw8p0?H|pPe`x&QB{YvN3V2T>p`%Uu<&;Z>0FTF4>RvD-r3+DARv>h?!0-r$D4SI zH)M~|tO3!u_(B=h{&}+&z=${;ne}L$4?Q$dhprbjdR+UU5gyP$sBdVUwyDO$in$)9 zX-QS{R3Ir#(AZ)FEYNmds1gfCxYeY*I=qL%8VvdfZB}jnw1)Uvr^QTJJ1y3UQs_Q6 zv(O{7+%qq`M7}qDI#4;Sh^u>z27P_2{uyN*F26+N`AgkySUhau$w87-6kz4j)$JmP zQ;_~GIM{x3fBwYTsXZESlSV7Uz%F@g#cy`#Il~~*Oek|5GHP2=phG{jVLL>EU&BTy z?0a{-FTqRZLaBSM$yq#aPTdtug!?`y>%tq@Y-G!X>B7lN7h3Yt#x7j4E29pV2J)Y% zX{=V5j(A3nQ1&YIv4f-gY8Fq)N;gI$6_ZiY-U7DABWvsDogd|GZrD(lb=coZCnd_j z2)^mwA1Pl=5mRxS=X5R(48GrZRh=CRU%3xtog2zBqBFL=crhV+7sy+Ooz8s(@MZ(d z0+YF-_3K3Cd{FqF@p`6Rg`Rh}EyJlIjF&QRsY*;a#!uPLN4!ive9B*{U^z7LV!}wm zGBZ9g2)1mj72c&lH%BSZYcblhWOHL%mOSZ5W1AZ{v!JL+i`igYUNVZ+d@5&W?l5(H zJVWR0WdAcyTPxJYR#KnN&_Q&k3uOqq;{W@8I<@gcsPVG)I)v>CIdU$bFz}LRf z+x%GO#_SsP0Jb3@W(-*n{L{Gq-+`i3#Q~!LpfpkgW`C#}dl=TlCoEjE--OFIg9lcn zJf9;YUX*u+F*$C^JdtciSv!`l1$eczfIed!i+TG(DM2#8(& z8s4_Ww}<8kl<4T(MMEQ-3X+@vQqL9qAvSbdw43JEZ_=+jkI4!|C_$vPZLf}S zsq)l%tQ-l>xrK&~g^kdJt`W-<;PJSyB{$PTNK72UX;69)e^HavBmwCIEx)+$1`iew z2sG#UV74Ozi!!r}l_s2H@(#Z|^den%xq#4;?-#n>K#NpT6o>m_tD_T-Df^i2G)Lub zK=qooh*~+s4t4Cdv#-XdMo@QGeDq>s%-3yU*_tmU{=d-BPM=eD#uY{m4#}}&C%BQ0 zTN)^yTka$w{y##{4J7%124i|1{#`A&q#)Q>kY)~(-&p66$x$7^8m^|rqD*$G6TnLk z;fu%sIw`5X7~$DEg512O_(kK+NUhD)+hf;sc;+r-$F70cfT;WZk|UZaD}HZebdBRn zadUT~VK2TJ0B;0{YwZ)o2b1h>MiIi;u@e3(-PLQ-M)K(uoSK`|V>7N8=Z-h5C?p}d z7)#$G1b2-crwdy}2tZkI0$?v}D$aHxw28tq)@LP6xVjeXUK?$8a>fn;+iY!a5L0|a zNDIB+7nF{?5gb#0eK+P^f+yerkvv1ew?zw=sP#IyXnW2sB}H%S*SAmQJ~g^vo3a?) zuxwQ1xf~^>J>8!9be%X*R1Z}KlQlg^F_rbGo)$HX0u^`I5j&*FYT!ik`+2_cijB0N3Ctvf?c5r3jQQA0r2uNB5_{L48o%&`z@nHJGu{g_!oE{9tEUsJoto8*Gtr(jC(pj0 z6)st;JwYk@V6nG>p6wriMaB@a^Z-)bG99p*mL`U_}FG#zNUOm z$PKS7FOnpvx+b8DBn?m6`Kh1ezBWC8((3ZrP?^niO^y-$?W?8)x$2$Gc9a{LCTTRV%` zkWoc1RCOCw_bvtbu}q_N$mz7q!``QryNhZa!Lje0Q6CEw1OyguOTF!~qZ#J3|5lgr z(MiJqhO6PQu5KDx1VxX%^wrdL^i7OuDDk)zjOX8=HU$KhFBX&ek}4qTbUgLfADm=} zFzBspasb5cx+D~*jtD!kP<|v@^k6w+t08u#bu%)x64w-PQVf`@ZH#CFBG|Rzq~)E^ zCLoVD)!&BWsFwjv1%@hj;qBr75-}?Xpc#^TK{?GAf2LniR2W5(+l7u)`a0Ekj|W;i zy%OlK16$Iac?t2bwS@#K8hix2Q7`C=dCMg$7MjXC1nnv_{0|!##l^yXhA8 zR33Jh9b^!aOi^Qtp84x;tjzOapqS()w^bco;flPqUE;6vys_HZeE^>RfXzB zM;m!m2YEWYPv#Tlmi|GU`gR2X&vt$kb+@Q0=|f1e&{m>IfNZ6ukNA*~7eXSQ;&gC1 z*|e~};l9mcwp=ayBPqd{ssl050n)J98Z+j?-SBy8`B9$vEc8^@wrR9mVS^v*tNpZx zW)SB?Z4u?9;PcFBzC23Xn|G86~N36F7L0dBXwl{twpmzP^w@X)c zjF>|z5=Q+J8s{LQtEb*6@SN9**fATp^kvC*?Z&BhNpWXc{<@l+eXFN}F3$&>^S~B+ z-8BX`_}o?PjnN!$qZ?z^)C8pK&esrg#clP`zk9DJhVI^J-8LDYV5pjE?B#`%fWH)o zq@=$8;YAdaOcFV%D>KB_P^y6hYtb~^u}qt_WKYs5B8_Or_#DETH^xLXgzEig^_L_N zWGU12WZ1i9EtulqK-GE`Rc%G7sW~za6dj|EQDM~uDf7LQ&5&#GxcSAeZW{N2W2=5# z9=Vb19AJ(g!luVG@b3L8DgLZK>?+allU&DNRra8Ef6SBHBFMR9Y4^n*x~mqgjCnDo znyD;`BYEEL=yJV+{_3Y&o5kK!TnnFf#=BM|Jc| zV`lrTy6zdVVou@*W*9L^gBsLQ68P^@5v=>)c4$6IWjB?5u;!jGOL0wHMEtxvK08uo zEV=|}YxHEU?P1WPrM(qSel9lCFBx0L-VHx3;jo$sY8&7^s1X5SMOGRKIP?IXkbyFZ z&6ACekeE+R7)&71c7oI;KWf)7r@x6Kg0y&H^ru$QL2{!>|f~hrd$8d)KX!bQez-zpdOXBk{uHLxPUD zS^1Pwf5@IF9Hhn4f)Yq`f6LEx<}}jE2Xe3X-sq`E(Ao2)Tq^qmn_VSt#@hx-i7FA0 z^BuU=(^)cc62B;~7R?cF`!a+P{*f8CjWd@j)>W$qTZdB4KIxcHEmmbN9)5AhD3hi04-h%7{T>6 zHRHz?H3hlmK+UhudL~S&rEqyG8%lrI7sQ4L#&bz%=8%SRAkY>)<`ah!R3*ou&Z3$} z2m~W3R;G8iME1#h<0Ypzll(k<1zfbdUvgG>*9z1U3h&saOH{->Ge7A>Q=V&^*z#8V zM=Y5OHvF-41G2*P0WBPR^tYn0!B&~!P7w8X|Abn30RQD8PP1ebF=#hkuz0~`hQ-;d ze%?3jzR{D-rL+7NJ)c(k03Pn?sD;eDXwgZM79Emn{6Nrq{*ElS{dU#hqlJ%gN2&(M zZXdO)V1HZHoo%zah-;qz-A!&lrk#H*_+6+n&3|ZrzA%#9kC7-j<8trnQ?@g|AP%b= zZ*C9VItMvh__$@c^c23$MUlRn8Or7whor~bh|`1L>XyB+gv6g0HPMawRZRl2A5Bk{ z7VL2-T%IRp!Vc*rKYpsu4kSm36YpE{*qp?JF+bI1p>4ks#N@~&u>T)z>CF99JrVXA z4bwPm^)Xk#UuYorE~1Q~0Ziwd`>qA@uQ9rOa39Kf=9{27G%4q-B;z89GqxHR&l4nd z4flX8*+}Okn7#=285GUzB>}$CX1WE$C+UnFb|lB`6PAL6D0m!Bu& zcopj`ZY9mwdx}LaWNWKCotu|&_fZ{)J{3+m{Y1$}+w(|+`y~g@NX{8?x2l0Z1U1V% zpRkv<4-orff!uXfct)#*=~O`8){$N9grd->nX3{Ih1%H`48tcwFrV_E)XfVORAFzX zOEzHG_ccb!=U}LZ(8R1Culi~*QV=q(brm=_7>@Qjs6;sc4$@Q+Z~i;tNM#&byE))i z(-YNZn*uYMjZQxZ_$Q`P!o071Giz`eJKmiR@1x4AC7`q?>WKk0Y0~?Q6G05IVuB?? zkmC!z9d(zMnDxI3pHTRL%GVu1cv~SQ_~McKw~{qHPs0v7e9+XzikM_HR5wkRCh+Ed zj5^)%qQ}j9Tw@{es_SGjFzb~NGQ5qX8E+}z5yxu!u&dS~5WCvC_fDSt9#hBLr`ZGP z^%o>SbYF27Be{q-uc<)BQU%VPy=%X84ZDGTbXDi9`bofZpV9e9Ff9FU03l9FrcwAH zKI%)8z5HnTddD^uIrjfp03vsOKh^Nop0?X+-LhS-_O`5ReDx_DN>ly_AY_^Rf=jDd zU(^cC%Yq7rUZ|qy4*9kAU02inn~uw_T`ukMypMC7zHR*7ajNogtOv1)-fz<*uuo1Cp>;QoEgcJveDfy}NyR)U3(}cIYahK%A|Xj+^}5b?zE(5q@az@| z-r~Tl1%;`VWU(lh?Bv*iB09k^jI0j*9k5D&o4zw6h&lke=YFxY2`w#T@fpo^{P2>h zuHRpKug`H_^);jfbmY_cP9bJcB<}+MNY#c4wwplGEPQP`)-&@^D;4JY+)aF@>dNRz znSx9=C`(+bwuB zQhiJ#h?&ikXkc-3bH&vZOWnAg6#h$QWSW(`LZ8N_!^kDzj-2S{gyDA1YvTb0j^WRr zSD8C3m;#rWv?vPORI7+r;0M(g55)@VNaiz9U{>3ZSR;(T?YQ;EzckIU*E~hN;$i57 zKaY32Li{K0J-S){z~h8nCrDg`jh%<5abGO{jQi)XSva<(<9HBWkH6$FJ@b$%#!FBS zC%+HJTtI2qEPW&2nGcb^CI7zj_sr3i*dq^)0QvvLXRL&WQrHf}yon)ve!Ha$)dPV! zR2+vqXnai?cPxD8s`o??`)(dSHF*{=`Y><3FX)X=GTRF7D()Vijxk0;>i z4h7I*bG|97eyt%;APX<Gi&vhK#{129kSFJIBB3h(*W_G<)7H<7kg#x{5>k%`gwSR^cIG6Kvxdg3?Sz_2fCoV=E7(0*gkoY6@>qth zpX)`5`5zklM})&x?12*<@L9p%#S}`L*AA(+q$|yrC6KL3s(Y11Gqxh>S?HYE#^8H( zF&E2H#w5=98N!{tePp8&@OS}8YmiaE_hque@$c(!+eC}mo$B=U5G&uq<4%yxI+kbW zik&>?vo8X!p{9;zJ-_A?n;$57*6~}S-^E}J+M>&F^Vr-91Ade9wM!=juhtF@#puyr zXx~u~KRTtZYX<0@kE)gzJjTA@DTGX`C*Ah?VfDwZe=(wmd|DBfz}_DC;%j=sKv7kr z!g29!mnh#J$`4Jisg_uc?cRYIVk4LG6ooiOZ$A01G9Xpwfo4Xtg^Fa^%tqH4>w`+Q z_W>&K1lXqS^Co&%Zs^~A#||zowfoj?Z-0;IXF)|NvtV}b7d((w1q^}xGleP;zAUZd zV7RWL?MQjl@h*nx&Ks$tl&f-dfwAf%;x4k|Auu@NiMWvy{h0)`_n+IG4CLRt0%#lY z^bdek^E9q@e+x?PTd7{d-NZm|f)%EzTl?I=E6||W`y45P%;x~`ab(bm{8}}tpiA;CkK)H7e$E;#!}t7aVHx`=uoxAORcCrE zhV~OR1>P-c;26UVG;$~~Hn()B>N7}2y$uA43S#1MKe6O6RFBOb1J#3|@Fbe9AU6?K ztev$28^#*$;q2l!Tv<}cdb1aOcKwR6cFy5u1OPlQ2lu5fRznPs*uzBNAc^0LHHs3< zAyV}ykyaDP(L?B55c3Mk6fq}yaMmGgl-AwNUFUyl5mF4a4ZUMwx@KkP)-d6&xpnnb z?N&{)bbU{dT-?~dP~dwZ{Mf(vxknWdL$lB zx+7AtxAM?aqwGRJcXMfY>LdPO(u%3c`kRH5`5=s0XesDbo5qj;HrOn!X?p(Pb%!w0 z(7BYVnJLeS|6#Sx1v3k2O9}C!U*MVTaqRRz08mokmGZ4q^;CW*?M^5PNUoVmdBAZj z85$0H5s>cMR1vQ}1g30dfxk8Ux)^-6PElpn{BYie? z8`OG2bdExjA8TR6Pwk}T!WN^n;?H*tQ-#^?wc&Jyd*rWp@x4D+e7CgAC{Y*8h^{%N zZQD8-Rs$hP>u|kMnDf`l3G|xsxXOWkP-)Qap8A8lA5?T0r&HtU+O-t+$+Zu0GaE+b zUsrY1kA@}}4GF}9+;r2QXeut0c2sT94Z@2|U+?xjK!>?FjsA6mX*#sKRGeDF?54_d zO|%EG-_PJs^z+x@*?ftmlqmU`&bfm6Xk>5KWNr8ApwoU*PPBB}oe>bL&udx5QRXJ^ z(!P_|-XcyNIv73dy_@v?ER}-Y102X) z38~e^-*Mk|F`9YoLCo1#1CxU5m?=~^Z3wnTlE(y3l4Kbkq^%u(V{290*%9!*>6|!h zZOZ#n;r8n8#{)Tvfa<)=xAB$W<3lZ^M%71~S4EX|XLIa?7F;D3{<@{*qJ!GpG= zoiEu#`jPB(5gYQxcVM$-(Q-ypxw;=IEP7|dO;1`}*-ho#myB>9d24Hbza}c{qDd&7oz6xQTke^H!1`>1{R z>ny=vQezz83ZLeK(#uuf@pF;{Yozmj;7PI7NH6rx{xjR0IL@eBNg!wkt)waCx!Ap1 zYuT0ghFhvx*xsnC@WMRzKq&bvwJK^Rb>0+N^&x5!T_dy`=hrD{+}S zW<|CINE5uki!2xU-u+A6W-MdMWC4O~{xcy;h}VYF%~Fb?>-Z|iu>RVv*U`o4DisQa zQ4eE|aJjplKAO1RMCMGPzMnN4=0E(kc5$@=IX|R;h|klWU%CidfYCqUtE%@l(i~N5 zPs1kBltrM|7(X@r!CdQ@WbOA$dS{q#FM84q@3fGd{JYzmYxr=NkIymUKy>rp)bnuX z2lqY+mF}SdJu4{dI?X?y$|hxz$>j>ycGJvqLRkhS{@FiwmwO(EH#bZYN;xJzfufho zR$lr2S$I#HurO^VWwpZ_W7SuGJyMZiP9l@XFai)b=wNEG>#gjQPm96Z7*-Nk%<`!i z^_ES$i+C?_?Azc6i)|NkF*^ZqIP(|)+OTA)%{@3GF@KGyv#&s7E!xrvI5=zq31Tj# zvNCb?HrwS|B6%{-lzd(D)ulkiL zxaz{f9|NC%*ZcEuJA|dwB)pHN97gNgexw_h_#i6UZpa;Jwblq!d6LJ}%~PUC@D38Q zuRMXye-5JS`#uQl_zEG`^M!dtY3Ug94&{1C+{gq=IPZUw#+_z+nB1jt9=4ULnN_;^ zr4^-FA$&yY+tPc?$MKbFOp?m>BV4-#xAg%~H}tlsa4p8E*{i`G0(}*uZW@0@t_w`*@con zXI3IrBnrZKQbO*`!u0`?>}X(yE5YeW&A^llFbApA$>XN9t&`20&0j^FUrgW+;V%0w z(Anv#Psu6EZ}k$pJkgwg?n<46GsFEtJEl%!a5q@aRMJw}xn1EoOI#0a*fJR^M&M| zLk0U8!&FXuQVAgM&H30x5c2KLF9?zo=6<*v)V$qO$0#dpxDgl*AF#h=8 zl{&qg5|)Hx%;KF_VON67!)%lE#*Xj6I5jEoj83uXgSA&4-;8&K?JY~4b>C}5q*Y_=P4VSDO;oWR$z+sVyBQ&yYs`3ur8=WQoV{*ga0%TI^QXrTIX{t~H@B0g z(_dkFmYa_GGe*~S%6fZ1uiWGJWqaBjbb=q=atyPmlSjLsiGASjH@2kAZo5!T z_p1GO>`Qyzb;o{f73yr;SwmAX#+Kl}hW5wj9z45nAo-;Kqv_2jzgJ$%*S?k8A$pLP6x)ry^*m=R7$6D#f^Ms76Vd)l zOI*JC^TI5T&USO_dcmXT!j#hgPWU+Fc8EP1&Zyvi*_3rDI()5OM)*URcS59i7CCjg zqo#s6v+dCyyq^>M|9$*Svwn~I2E%LkH9d^+I zXlD&vH5cEQ(WVNxy*pm2FJGtMwzuL_NMY9Bd8c7KC=oon7t5|0dP*#RN1c)UN6DGt za+wn}xkMCq9DLAZuD8A?&W~P?yOV9UU~0rPjEXfBwTiwVqpN8g5}ya%HYiE(S>hoz z%HWbW-{nS(=|kv}TYarsnk6GEDGlemoegGd->bq022_s7$7A68%aI6WdG9SJclX@d zy2D$H(T5k##ZA8T*cy>wbovW41z;Dxgv}RbDan>O$0?X+nv02Msu%#m9ca_Nsq*A6 z-`!qlc!x9xMNH3&#_Mo*NH5;-B6XbN?7SynElN|#zWN}(;sbxxgB{Zr3)s>^IA8}iC-Lz5jv;e zHw>o+2{@nNI8$gD+1m z*O-HjiQa7Z`T>1RvOUP3y5ZO~qqq6O{?|wBO4c=yac?FgxrFW=x-Qu5Cyy$21j1Gu zoMU};0Hqvb0&EhciuIbRbywvB!c3;G)SUBtC~?3W|Mph3idEKIPmJ8ck(6?}*bB6N zoM=Dwd_$Z#wk(aX6?*>Q#<(K1CRxZ8UM*Lh1qMpyMpO&0au{n{NvlH^zCGUCY{mT5 z!f!YKyu|vr1bZHTKZSHler2F?V^AJ{S~xK>ITgQf$7pd?50PpS=`CU^fg_3pKz7i6 zlGbl_MVZ-B6Mi#g#N@9-T^4BVw>?b#-|p^Wg_T6bI#uJNzhjU+3ksE&%zqNSh!I*R z_5?m>P|+P}=|wN&kIsiSUtjlH7je5>F;3;^rk{EdqwV>=8N09!^%hn~JbSe+n&;64 z%I&h{l%_V1)eXkBU=sX*ixNM%2pu<2p9G`!Btkab+|I^H@BBk9E0 zQk9&e3je#sv0(>Y_;FY)>-VHWG+Y>e&J7 z8l;WGMO^%ZsvlaN%!iC?)s;SPoTg$NGQLFRZHaS8xpJ&-&p7<_)BVoAOOW1eIeNEE zX*7fGfM~10CU30_zLW!|7KG0I+YSw$8;p-QPq6>?HC49$orKAIzj2FZ-48V%nA%yH z+Fxu$i>EF6+a9-iDaBvYN)QpurTe&vQFX!xg<5KTU!!Zk`L-<`%@Qw6ix&m!1&9ii zAYHh4W`*84F0=oj&khnU3WuY1VvT-?1zyjy5}zb_bgVctNf%EhdBDlNaGfDbU=c(9=`zf_j9ZIt|X!%T71s)_k^4w zfmd5CnO%1^VH%^$RE--LT)cpqo36^!rr|o?s0y!&5|mpbhdCl&a5Tl@d)Hi`r=2Tr z7(p(SeK=I=La6fk0Inn48AIoG*?=d+ZGC@q-;2dbrI(G4as-S`dY{qYY8Vi7aGkbW z=eE;A#r(xmy8saFWu-Azb@oUwP8YTkeW($dZCMLA3~Nb2lR0F70EVKNFL4dkv&PqF zuvcCWU!uWPIjeSuG%tJ3AOpdw4#+nf*?!vvxxnnKqw8Jm`)_Qa=u`Wq4GUjT*n@=< zZ3Gbz*4~V~N_>pmodGo+_(j^=D#DIh|=MHTDf5Z@yPg zmt9-@!_`vuy1KpEkWH;8#GxbSY=T%eC5Z|V;rDPfNH+B?>S=orQyMiq=I=B|JKJ-@|-pf93Vc7L;g=Kd_YTCDEh;2ASlBQclMymT65XU zb0G!wX~6Y%G5-Ym&$Zu1>Y?InE*#8AlS0VyJ3dxGWs33pmHbvaYj!D9Jy_*f0QaxW z#x-~LxSBvaN9Yr&yztbC&+!v^oqH|JG(lu%<(4n{>0TgZ=_zrJfmzNOr(SHyH=r*j z8P2-;oD}&P_wY8Sh1HP3;cilB0j_mN?{CRohjoN$U|+XG7^g&NfVW)hw}g^Pq)`A z&u0GUK=y22U?jg$&|y;H2bm~1GY)n@J&W9FOf=q4vkj-qGU~~up^inbtEb<#bzG`8 zseW+phzMRCKkLO8Ce(5&psS{7S?hg7hBaP7HIYP|5kUIx*Mh(Gc;;zb93+0UB+yf# z!NNj-(M29?9uO@P;q#_=zdkIhNCR}uH(uRd6bV_z-^T$TS8FVKRSCJ2KcHuo%ugAY zJLSZ7`r$-}j&)qy%fiiPov;J!F42pQA#FT_oHq{F9524iQzWM}EGuL##bPT5xPI!( zcYecYv=Tz#|E*rb@76xv{kr!gW5KuR8Gg;Atn%wp)7|%BR6&d*FMb;mH3&fe+ ziI0;5 z--FfxVy|cEiVc&nuH&UE*UtqwdMTeTLI$+|);qIv2Nn2Y0=yc34C!!iUiZ{cjelV zHH^hz`9g=Pq0zZ{@+T)Ob?RXS!g^&JV9CGCzVO|>lxUCbFIq24TXxCayIe6^M|Ed~ zr5=Jk2h5OTUfPi!I{f!Q6sBrd(zs1N5;Nx&HjpX-BW#7mGK*h3OThQK08sjOIIB=F zP~YqCS!y$YZjZ`&a9={5GhZ{Xv3K_1MB5LYfT1U@=JxHwK5Km0uFfpFsQQuU#|b(+ zS)aiY3+geV4T8_nMseSB8a8+w19DNbSL7{q&K3CJs`td5hi>P~Ntpghe}+rIyjaWY zNwW$b{QEphPJhF=gc1MYEp0R+1PEq2iw}n|(#!OZDBNH8+B$ECs>OR{7p`e}{vcgP zv{VC0_3|Bax3~TaK_6-a>aI@&+M({;E|mDV4N_12^_6}9;v?>Twm(5~MmG;WJPf#~ zP#Bl?#z>dvD0W~i--qxsJ?{XGGqOGZl6f~o(WqYxQ@+d=~ zaTj6%>>V_e#Z>?PX(`*#4cS&%Q+cx_m^EA3La>mFONf%RKOgeI$=?W63wg4#%kdF> z6|B^DAqQVes{LKga;6vS<6@T6{Chg=ocbH4gmAJzkEzeE{W5FKj5u&t=ayG2KhwqZ zjj8J7R31_`IN4#eNjeWBd_rvp&6gDT{)M9VJzM6!<$gqR-Xywuovy@-mDE`Wi`9!G zBJO)4(z3$$^f|rBH4mSP`sMA?fHm?)A8*#^-5!nuCr{)A93YG+6x2 zYDfft-#QO7eO_?jHv3Adjbw?yA5sLLC&@?mc#UbOxan)iTn_R5`+`|FafXGh`S1R9 z0A5amAWgnIvQ4}eEtn}tmWW2K6+*py_p)B;nhx%IQKI3RHA`+>ka!@NC#Gxoc}z!c zoGPw*48~wR1JHHf)J`70zf3X(iY3)zSlC1)?X9iyz=Y5qmfL0KlQryVYG8S}w<5&+ zZ+Q4H23%(Kt<@{OJ&2#*3!C^Yt-UrSBQJi$*upIBUFJ)BF`vE@?RM+*Z#V_018~W_CUK~Zhj|S zJMOWUAypz6lia+4$F5vAhB1|3jdP(tHCZ%wX=OD%KxTj_mi*w88za6}Lcl7502>Yc zr*|iRDL#7@Zjy6G(H9xNo=5;(hiV7{CazYXvv4A^PcqUu$P3<~2x`mM=V)Fb{;A?9 zfecf}lO81aWADnWx?m{;8OydecB~e7q||%eTG^0%38nlW!^^?_UvnD$usN-0_i-)# zEVs7TrGBbi7fRqDGhTM;qJzH@cALiovC;A=kldyUbq_&p(jAl|sa1k*)I@%P>`Bk9 z>5el1QMKdu@6T7?Hqq5pp?yCRXW;?3K$cOM6<1j^bFE&`{G~7p)e+w6+j}fiVipwg zv7uAT(wFn=J(-gp6p-&8ES*CXfiEYc-j3NqZiTYedy-UAbgYxc!nu6`Twu2lW7U!O z*+HA&zdE8TvOVkd9t!7IOKF#M_SS6~*;)O*LRcH{i=H&UFa>~sSpT-%U*`R%14W#Y zsC=-pYv>A<559BZ@TJ}IZHNeE!l(ie^p+;G1ItJiOwUf|>;2`b9U9Y4!60D&hp0FI zhw}a7zLk(HQz=W9sVEiM_hqJ1R79mLSqs^XeI14r$udciEJH%F@4K-N*|V>M!PsZa zU}nsA`+n~Gy01Ur{Ox?4$NN0q&)4hehI1ZbpeeK@QEqD-VPpr|ce}OVOC6Zf{c-Xw zwf)TGzJ-GU=jZ(xo4jDjRyBW&Qt;Ab=++(@grlJJLJ3$g8^T2!u0l4r6dIktyxFrL;cq{Jj9B+{aO=;$4{?z_0&UK<^?&6cdh<+hXXnlu<96{3>vk+-Ps`Va1gtE++?%8~|` zBc*_4AOA;I);dUisyu+)rQ4l#KHRlsealEo>F+$qnvT% z0f|11QDr26{hIb0k<3Ac6g-V}_oS!yf;+A;$^yBmr%}@g?OWV8Jh=R7E9{TshP^Tp z0jxU{3Ic2K+jOqxYaHD-q9;iEr5Bx2((U#-|B;)QcY|^gx*piZg`|+lMob!}9!a5~ zjv0^G_&PC2-NnGYfF=oQTV`GPo_LL;n)1b1?#j{%T#OGZ@a`~j(@<5Y4sopFGAs^7P8t7dj9=zK!nG~Pd zuO87Z6f~L?L{v?t6&HN)T20MYvUcPE`SjbzJQJlG_zrM>*)RIg(7pKv1qoDDPXVVF zjo!Z}scA{nv!<2vsmIe1O@A7WLjGGpXzP2MX8-lRGSa7;_^)(XR~P=cecg2L`R{*} zv%>&pp4tN+uRbw1Xm(*{UCoH5)=<^MULT6P0m_GE?_b(As{2;NT^n;yZJq1Sz*{z- zH%HXF@u`ZkSoaqbSAHvk>^#ZStLxt(WgD7?nF+g=={+kKQ|FrHJ5uEBNQvW3Qd$xgpL111g6jR<>bhXb zFCA%KZ1)d0j^tr=#ygjqV}LUR8V#Y>O*@o+qbU5_x+oYSy|6xVkx%nc;l+?MBU?iE zSyD%Pbk$fZKMBKYGtCg3nCeSG$&lB3ko2SbUZ1Pq#(mM*xAu`zw64i$nvQRukjRJ3 zDW6-$fucHQ%w;oQZcZ|Ki|Mw)VRJqpQA%)=p;X!Jne*mTYF?rt5k5An3$5Mkc-KS^ zwJq9ffeY3y3e>NAx6#zivTbI%-<~mS33Iar`-vdgjA0deum8?Ae_T;2+>IaOQghMpRR;|zLx?I!O9WXtGc|`sU zr*C5djuDSBfI&+@ohj+aI^uZV4N&i#@i~0EgHYj3rEj&E~gWhLDzW#i8DSy${ZR_GX0oX*+E5P>(=^{$>AX4XXwfSY1n~v#qc; za+eUf)if}Yo`DPufiOPt)gHo4%d86zSAwfK{%g=XhOqt_T`~6jGheWAQ-SvZm-)3i z!^&qjXZC!Sf3*U-ib*9lv{gy_rus6JK$pihJ}v2M(sATycwbGh193{}OCH6p>Hk;T z(Q;L_&k^E6wC5)xQbs64J{$3``lIHn>Q6njPww&_=Hc@1%?K3C?5P1&KJSI8?e2Sm zw;W`b)rZ(C5P|D7VF!nQe&2pl=jgemO*pBDw8>Y2nqTk6NDBvjmVlt{KJ`(pIQ>md zA|v>67`_*5xAp!Bm{GwJi;#<)%z|>vE(6>N><{@*l1+o5x3#_9dz9iKv#AzO5)@n4 zT{yM-jME z)#yzDxCl!Z9y)1A%Oow|44*(ywJgo@SgKI_q_JzfDIpL1&jUn{Q-Byad=%W~U#&WK z2G680H?d*6iDSOkK%tS_I#TRX(Ud>D1E~I4^hhzX>@pvsycR#!bg1Y?2LXym@H2 zfXC(g_bh|q5K2O6s-UWNsyr{1Mw_lqjQy40YcwAsBD=oh*_7#RCn){G(?1(^Ori#A zHO#!7zVw%gF$PqP$ZYvttvLj46f3yt)%SpBr~&v z3r_N&sXDU;e7R0B2!r<*z1+q!lMhI$>xO53PnZ<(iW}qr)UcZ8qo(z7wD)X~InU+N z{}r$Sj+aL<2?jA6Vawtqwzn{IqY`{y+QN@-#&Y>G)?aYW78I$gQ~jJc$G9S{x6{ov zc4=mr9Io0lv)&7jp%u5f=KFz}a*c+=MSGsxc9wW458$6`+L&qQxoKzr6!8fA(Nl*}_^8ZvJZ-er`Qb~kc!Jp>lsd-QHAGY`;m zbR1WnT0h@bw+qaaaJ1P2+XYS_G*AI&g1M1_`B8k{$I6fs84XJqP0&B)4?qYSGl1TH zlDaZ(JtT$SG({efbzs}2VhGAKY%Q=$Dy);5K0{cuKYl9?dxQ%I6=K*%8;&Jhhb<&H z$$!7uxV3+x(S`~M24QaiX}P;ISRyKL+UgCT0?5!(#4*m&@bUA^M_IU2=bh4Dw@;L> zdTRNJt&gQkszulwHiHA9gIIbgN)Or&3!uSlsJN%rI|o%K(fg1Cq*cGzQ=6yL@n5+>Antk>Q?DVkokIct7!P=cJjV zB0N=xA{47CLY*cL_BO>t#5a%hjVRX`lq%2rNqe~$^;PtKK1$4M+DJouX_;aDZ#ZD( zUlUVyy!TGMuANd6j3;Mu zwWLn2KWofxIPQae5RDdV!s`ovvYOK!$`*Y~W(CP=jJg%RfR$CAi5E6%Q=k4ArXswi z<}&29rk=F@16Au;bkp;=AFjf8nW*6CsflbF?wFl2w^RRMY)$2?S!>?+E`xqXj_Rqi zAN^8k?L`({0sM7P;ERjhi`=TagzNx7$_E@*zH$5yY<=N)x5UFFa7RpSNOMX-0NsAl z`943NUUTilk5?5hp0UNoalpT-hThf(JS;wXGol<7#QJ^1&VQ>;`|r`_9ONW?uL!v& z01Lg8Cy~L*+B`oUZtZs*+-Nn-t;dh-K%BH;eO=d@_@!Ex-$qoz5<^Z_|JiE`7B7dX zYFu%Qgq9YCN@Xu#qM8*AMQ{lG5;peyx>xOyoKZ`xEx9czb)m2|6A7raSCm7Upo`$=e0Yv$$qj!3*Nf zv%_3rjS$3+z&(G@SXobnR4lRgUSGLg6s0Te0WHv20g~z*uPJ_oc|=u*fl~ShQLVs2%M+E&FEngmycH zQknJ+z4((rdD)T!f$CMG?=0d{1DW5!L~$iqq9V*S3hYv_c@|>Yi2koC75!&h!=;Bq zt7|3Sh!1$3*;w!u(UWg19hpj* zzJE)%d^|c8p4>Fl8r4f2 zjdH{ki!yFp#=<#CwIC$s?cvlnXYKv%Ju9p3Wm^IGX>7o|bscTje!q2v$Drz3kBtJm zSIe2ru5;ybn@>(|#wQ8R-w+yYRsR^AwmEwf*hbY=d zSXLzH?-s?zfz2A<4pBeutOoXqP3Ndww)iGiZxQ=Q7^C@*Dx9`udstkvjkt59oa+u= zGj{!s&*rzXzw^5k8d&_+;M3B;kC_K+L)(G5AAt2mqYcHKiyKWWqf&qS&n1&iQ|oH;&Bd{H zNmXm7fI_O+DGf`EkA7|-xh5O=Sq9sjaOHw@wub5ux$6L zINEt=Z>3agw=^GlM8VVE?-7@1Y zcN9I`2q+bSk;(X`Xk4?n1e97f8pz(kD5woJ7?-dSxO==OGb2FHGi{vC|BH} zEB(s*&Tz=Y`;xHP$lMbh)jrkzBD$*7?vsXr)QF@9)m7x*J7AZ)UyL=kd7YL9Z3!HA zimLFa*q6!=RQDX5rIkV>?|RrYzi_@W7WWJ~s~5en6_sUjkUCq?v9KRNJ=3IK);u^o zs-$A0?O%LnKdDahMUC*;kQbGV4rOK6b()ZV8G1>$Ld&v!tKy_9WNNPZbHE^iN5Syf z)3nuj60JAbhY`1%anIkU_Tf9+rI|0fSR$C3*6Bg9IN=tA>HM|w7mA1F6yg>^s~C_| zEHtJResNK@kUC{VJ?^AKuBA#Y;LBO%f?PE5&fU= z*7yl9&f@Tx;Gcz3XuYoP7#r((?&Ku`;?&cgb5Tw19P3ffd3w^T*VOsSrj4b1kIcVf zy3maC$3h5104)N`{DxVTh2`!|VvufZEz35cIwHPkOO|KF^m!d$=2*VL*;9gHn-A?~ z<@9KW93wIWrUA`>dd-yEvXzXoZ^)XWK zh4zozrj1PVw;aR?1DBPEAFiMDu80C=XvMW0o^`z5joa%3W2X!}D7?yh{0Its5E`Em zO-x=jWavhwcJ0oM3%N+siwi*GKNtB z_H$KPq4s&L#T`Ra9i~-;zCzKgA)jSc*^pMa(K|jh0DmwGAL^`_7#~-D718sQzg zQDMxqO6=!8HowV}6J>431(~+zCnN6zsXX9)VZ0U1T!BA@JjeC&XCuXM>?QwewO zJr_g(YKy8LnzJ`-``&g0ToU>2c}^ixIE?iC?T=9Yv*ShC8zW;fo|A8~Ft+gu{DbP` zaxX!+?An5#E~-f`=!uH39S*3`w-MjbvA%7_D<~`M>j0DzQN=q-2jbtgA7&{mpD|*a{%W9Cu+TJ20sKgEp7oB2i%Ckdw{-Nr)aYs!p z6C8p&Srr=a?O^`2cS**En$8KmTkk%-dT^#HJon{`Ga6T`y8PE)d@#HiJNjpQBBA(I zgvOb%r|KX5)OJr!D>d9A3Duu8n@zQZE9cWqJG-QB@Bz`Jn#d1LH43HI?F_AcF-|JvW zDaQllUOmWFweyQfb)a>vjV=`q!ZIJY0a?czY4~_{%CADKm=|%dU0O%mg_9K-r5xc+ zrIW~$HhaqGAIF6>hn~R|!KK8PL;0xvH?P-4!87xp+vA+Era3qkf z=mbS+QPgV6JE@F^Y`d@%_=nF*Ui6>sSPlBohS>&(W@~?P^;|;exKS1-qtMrYvI&@w ztkk6~fp%H8rc>%`Lvg* zW*wV=RVtQbS1@`JN0i2!IS)eBHMUk6fNM z3hQq>M{Ti2oEd)q;vcTpx47ERc(rE!R1zgpfF8_TxA|(=SrNi7qWzfpPT%YZ1wMOs zsZA*L8|i$)H!oWtNLU~YzZA_GDk4hjMjf659$M%$f|&&`7A3<74k^ra0JE!)iwR-C zwr0or;X|W{en4~Dwa^hUZh)RrA?yx^V~boN^}sbivDEcKf{)h%oE+~l7$NZfg#vu; zvLT6EHAGuG0QuH`5ES7G*&DWAt~y#yeLo0R zL49FC87s7|oDEl(2spl5{Hcq4qw;wik10?)?5;Hg)X~byZrkLG%|$%Q*bE`ogCEL5 zFr*c(j-kUFjK9Q1h-7lIP2F6QTD&~%o9PplaPQLaNqVS?$8zN8pRnHZuTtuse=RU> zkHUSuT>LN*ReYO-!d$FlvbAFTre6UXbYo+5-a0N59C_>);+s0dMslZQC-G0Pz^Owg z|Ev+|AX*0kpRk2@;%#w2@<_Z-$w2sg>z4f~FM|ICDtE(*Lop@kNqRjO$h<8I2^EDtEmnv29Yal1{fLm+ zSbN`^LD4={J&na4>9XF44lhGnyEx@X$Ii@4Eg9zL7;0Bu%0kx8UU)@zdxnXF#_%{0 z^yIk)d1cLm>prK0FZGq}v@y2t0f+z_es^w|!4&{(gS~d}P-`!KoqFAH!?@GGgf44V&V>l-dlp}lnwV`lu&@jp;%GMFCSiq-vumN# zeX>X<9QuyY#nk^zTLPoe&kR`SUFG(b8v4=uanr*DzMw(dyXZl`$dq? zCz%$LOPQ%YyL;}4j%%_*KO(LBroKq&uDDGotK2#XFK+qL*LlmnK9x2gdeo-3Pp?8A zDVNqmw|G9bSoQNnw0b<*_NdLpLS>1Gu>CS3&_AXch$w{9f`$L2Kh znB{uQ4(93sT!w_D$Bk&mx;{IIVm43>TTcpfA8T+C9pZ|rE+_2UJ zf$1W3?6ugp);Hdqdr5ceapccmBS1a|<)5-e$=cQjI-^zdXt1FkWA1;d%C7V=wqavPnw&8R<*$s zn+)%-;8>*hXIT=!)W>|t?01;A2W<^!y=6Oy-;P!b&t(m<8EQI-P1C)iN!2$Rx*@0k z0E?=5c4c;#eP8pMXYJY|Hsiy0+=CmtK+xPtTK-h1a1#)phf2OSDYu5<}3XaS$yYW zt;H1HtfB5*w;QT^dwFQz?0Wwh<)Hg1>7O%*m()4)x6Rbn_?OCVtKaQVcgXUA zp-__dX;T9m5tOdMPuorD5Ao4Kdr8qkvCNk?B=-~mbh`9gL1wdDHSMnHWR_mz|rOl(sm7!X1q|j=|^3!DUGj)E} zvtgOyAQd>ldD~dr6Cbzzy(c5G8r<57b5_ERz#h{+edEq2iP2@ginwX3sgiQ_-;H zHO2stPeaRDotx8uF|vSwzgT1lu)r^@KF$@Ik)3V&ol!U==owWUtR8M>Eswcig*_iA zx0KSduQ;;SYSZ%W|Mj1$sIO%Y?X7y_F|U`2w={10_l!_;waDMoWn%{8-t+s_?iqL{nhq_+jJ{ap3?aS`nJgHWx=ka=2(nRfl z`qLywkttJGpKjF!LCJ7F?A0qIku-JVS%O7DvRzkAbT6>Qgi=>3!lkB1%-A*tkc^`8 z*ijS!T1~yoVx$N-UiL4ZisO{NUFoOv`bt-$9P!biUWk0@neCHxhOc{w5*pz%;6(;lkFq z$WD^|p;M3bT_J65uA>(Um>?_1FN0 z<_zwx^^iNb`7!zYQsz{Zcgl)DeSvqa?O)u**6|uOf6Y^)?a{`dZ1psF*opvi7;Y7Y z(+ithB_h{x`aU*bQ#QteY3S%G$3FPacauMB7oPVud&bkJ9HS&*{3v>o#{q6}@`{Ra zDp;?lgPvB_oh4?|6@<8AP8T@cY&ZDZOa;yR`LJPiNWV<&o(CmRRx)O0kgPTY`Jb;< z;Eu{mz#$_c0xScd<3D_Q0;T(28&40Ql^&f!JUXVL(M7Hv~F*Pf^FgUjSfj4 z;*$g)Pg)i!1I4e-5iMcFDG6-Gka-h)VOrTINymD=d#{sU>2!t%XDOM9Dfzm}rPc`5 zJ^1vLJD+#=Uj|gy3Pfy}&Oj%3FAsP@-y77DRwU-}qSb~^qn$p9klhgX+u?_q!t-*U z-~ThRzNcpf-Ev85hDJ=>0zOa=yzRgB`A48#+v9|XPCg)CpZxC~B;(+x@eaB;xWo1i z=flZ=g21QsDg`0iV=mMgjk3kyXm1l{H|PP|cA+3P>{6=^aAMnzXx0CWCh__C!xX_7 zWa@evvg%8KVd73O`F^2xc9M%Fc!D_8iaTx%yD8_tki)x>a z86|!K9^oq#(El!u~9$-5?#luY-;PDIqdc9*a1=~?=>RUBM z_Q`ex+dx*%K>y{3F0DeqSfGp8piJC0W0-RyI|pWxEu2?2F~eU^uMFE>r!hJ@nRPq; zfee%1wu&;(WI!+W1VSaKAhO}7Qy+8Z<##;ocbAkU?09(eYU-bV9fS#z$1bgn|CYoq zUbnPn%?|OGG3C8}N&_2QPm5!W3c-#rpkt-r)(O(V${AYq5QXsaPRrJupgYze%L40c znSa`l8nu1Mi^eEwZ^Ri=zYYHg?~*(>bZg_UGt?mfYL*)VNnAX#)8P{WrDY(fYU?87 zvJ?r_sox6$mxMinaTy<_&T6Yi`c3t;B>6dY;4WVOf^T6dBFhtobI(qwK!E%*Q@VFT zk0d4+@`E&M#5|$6-dTnpAnaf4G}fcW#%A%4$J#IlK8f^iLnMRGw9|YMw{=s>lgSmH zFAa|q(_jyddNewIqHs_a%e@wS?i5o;zry7t^L@ns~?WJ*ox1 z_6-vGjzaHkGaB4$v01kz|GRQ}NO9_}*tw9FU+j_yZHSER7YDXU z>kA%~yY4Vu>Rpop81q%e|*5FU;6=GJYZ9Hg!p-Gd6D;1FMbzY zbHUinO2q4wCAgCDJC{j%e6`VhB0R@c=5Kdp)5r&|Q(;Q#S`tPmf_#uPYkNX{q2RXN zP&Zof5`*Ga7wn(uJpnaB+8$*eOJ3h>74D6oY|a%gfh-#Q)DpBD{4c-A0E~at3F!7h z*h?X?Y>=ijrB;fz%84AU(|zS2j~jzqoV#LoaGy}OK9>v^4*FwJbiQ22SyR5P*t{!P z?P>%EJ(6=TYTFJQbV)mm{{kAt^8H^F0I_bpxJFGN{;DQ7qoSF+Bmjc}%`sW*JHUMp z+jI=u=#Ndq$o$$>FCFn*Y1@zWj@vOl%6B}_tEq_*1EjtPJnosA%;2yT`E$SYYSQ`4 zj@yyzM5@3yP^KLR_=`{rtHcRmfugn@m6qDuVMp31=Xuy2?R@SfRr{&nGqIwN_e7wE z6B6)msN)YR8D|rQ)??=O!I>-ip)KGNDLkNMDJTU;9>lqxu&4>oD=~h&^}BND{41DI z?ofFy>xA^0L0%-_?1f;T6xJIZ(HQaj??usrKg+}BR(T9>0$=ZUs=?{GrwmMJZpo ziMk*(#(D0;EY0C>g{*eB5XoN@ZbPfB$$C$Q%1I;v~vfnUx1BRDp2af5cQ`=sx&qhzDE^@f(0-hOg*WqUji{czyKeYpjT)2>T# zEO`V^w*t%A?cH-MgGXk~ZqbHUWs)IfM@3@sJb&a*BAUO=@VeN^0!No0Idgk*bbe2T z@U)^QM@z#c@q1&VMB)ya9`}UG6%UoSTZt^uB3?xF|==dhA zS@4AP2#=EQeq3qQn0|@(d7y=bt4N;Y7I*QzN^wf{A(qYy2_C1Zie|!%Xk-Q9$-~s) zB~ulhuaKh}tPN-z%{EJf!h6cr=~KHFQMVKYguh%yA$hVQ1%s$_{Cm$Ar9p zx}pw$;0bpUpjX~JOuyzR?WPmW(r&iWnlvl;{_#CKrS~Yje26G?_Nhnoc~nqq5@tdj z=9om&fj6U>gKV`&^$kd6Ct|`HDPCLck^rhkc@gjLa?L2A`gMRTdn}@EnwGg!08JXc zk#}PN3Eh{1-F-JkT+|Ee3CMWw!?bNzErmYN3{LuySR4Mg(`?1EU%4KfX&FI1cEi`J zL|@avW>0v^+xO7Qf_bw$(u_0HhO%U!>tYM!4x!59?XaKHdk)rt=%uwtw&qEByh{A* z>1^*|$7OK~W!jXeSZtNrUj<{^u&<#6j>x-G)9<_L>w2_uxZ1_yVsed&FvHXY;QPw5 z^%UBj9Hm+TLJ4TCI&x7T7dP2)`{wc^TXZMMmU6g0E;%1MB2Q*K^ z+f>Ho(Qp|OTVdEQQY{dNkZ=3O_sZ?~_^_&5Ivv^}d8?Ihn;X{Dd5*(=>#g*&O-(8?;LJ;c&@NB8A!6i9HUd6*vOYZO1os%WTL0*O zE#djw-w!Wo<#UxpLJ!9jbgj`OJZ~@0u5h%W60*cyT(1dw&y@45b%DutDFEhgcq6n! zHMFsc^neXSf`lEcQe?>yH?uArLmH+oV+;iPPKM;>u64}`%t_6oaWFOBhs3J6yTPMo z?|eoI$gvA%4=>`}G){Bvm`ZH(776R0U7%&y{`}ZXcN<0bs z3#eqII)<4g`Y?Z`OPYWV<-8YCZnlP7aZfu=Zv>J;JM(p=#OnUTf&(PNW5-}L=%!otyqNJwh2#6gZLrN3 zvS=f8IydNeviJ(I`Dd<8@0Cxr#fpo%&I9Tr-VFPMM?M(v<~>)MDG9zHIr_AF;eGAx z-{1Zbg1Xy)2n#pk8tuEL&Cju`yQeS9MmEnBN`0{XXFd=K_oXmS|K3xdUqDhCyU8Px z8V3$5l6xoAV9}3K;;u;w-3n0MW(3Y$T%XH#yl}7%Z2o7|0=|mMTp+&3?m4l}*=SFk zLBDOf-6-r725qm8fdKyngbQlfswEDhw~o)F(`eEq(2EHqwK2@+J(U=55&@>~Awq*j zNX#}C%0`RZQQMzpc)MQ>vai(!9G7(@3;5vgNlU3 z2_a&3DF}nde}6Bt{tn>8jNXm=I(Wz;IZpn(DXmXz4ZMv}N$4x>BVe>WIwln_ph%=D zo<|%lDRSRnM-HWR&=nx%SD!%W22FNBfXL;^+(VEH`YJ(mF6`Q$;jw>;ciPAgu?-1x z3wM6P8fmDaC)I7ijD5LeP#l6V-HiW`5oz!Lzkb*2*bC;Tx0U0*Uw&o>X4ZQ)@j4K- zV|COWc6pIFHcbr^PjO)I8zq<>glu`)KXc$lm{ELu!h<-oilGNq{aHo%{O^<}`O`#x z>H}Z3`VB2@qhXxUq9qjZQq7L~7=*?(pg~O%-`=yQ(UCrX4djg?fY>(Sc1#j3Zl!U>IzPSfdW9{^!SHm5 zeWfluPq1$cL9P(Mru!PooUQ8SMNMs-YxeP_mfmh8R*j~!7D-2@w*fi$*d3NBM-ziW zkIzq;iiCO3sHGb{#+-OV9(1)wFNFEQ{OP`6%EWA&t?uDY+9clYdDDA?5^XbWcF5KE zQsFCl+L5Ov)#Ked$C$10DBU>VxseXvVqNQ1z+YuW!`AyT*~%E-3AUEW@RS`1R<)s? z)dAk2H|{ejr3AJtz=Q?AUL*C|H7K=B1?n4EBDVB31G)EzML1!~f3RtYbYLKLr*z_y zJiV{YfBXmMpn)MG&qflhU8(5o*E8n_0Ec!5(CS8#?i~G9dN2EN)h^sOZ*MsyOHOj= z-UwA&j@_q>PCqz=kYueENZM!~6zM?sPb-@CrL>ur!Q2rx-rCz}$dA~#c(gHo5OyeA zfrzXw$_&2sOCLe9PT`Hs*z^2-n@R#69Z4Hfe@DsWvHE#<-amT&3K;VC%%{Pl%Hs;< zJ7YD`M$(pD&WDO&8QEJQ4hiq5$8O-as(}?S&gar}e8BIlcu~ur#Pn<0%*b%qbLPba zqe*5|hwRD$nnoFFYTC~&eK_>;r9fIkL3oLTvR>9;k2Q+~_wM6l8M@tdEY)W*eWD1fP-g(qJItH{{@H59$gOk4;-ExY-Xs}yotFf-^_FU61__+QC z(JLaEzG&K4hUjqD1|a38pD2%fiEe}%fmq>sNS<>`h(6S#QVNH{UYAXu!$Db(x&$+jFKY6i_$;^c#1-92pTe6j|879vVdrjT*3Beq3#fuVoG3T- zVpWtVUq&{+f7XA-%>jDbqW5>2?d3ZM!Jb26!nm2AVr;C z`5HB`S7b`>nzjGHdg1ZqqdhNlrU~2guCUTyrG>S|BL!7k={Q84;YRsdh(py(p5#Q% z6URB%Bc*skCh7_E(zDjtdi&wzkfp^fpFtyZWJjMj@d|U0Y8f^n+rY%!3qFVfBbi-b zZwd~)H|#fE1a{OHuoXFq%}>un&NQ~ z?p#JVxf`()n~^;i9F>&4YsjX(ZLmL8G@2OD$r{Ty?Xy73tqg4?zaEH+9xiOnSrMPO zU*A4Hu%?4?cn+lp0*5KDtdM1IQO);c7VyPhx3I1^U;N{9Ay42F>^dar)fk3(Nptuk&jM}E4oXUD|GO5Q8W znAHg~Q1az#=8<%Um&WqbEhqFvW}MBoU&7LCj9B@({*e+Q*jEa^*??K+eu^T|kMDL4 z54T}QDXCJ|(W3jeae1Q1M{n&8;LeGgM2KVD7=G z87r7g+=7pNd;iZn-ED0{5yWYD+nFTwI5wdYqrAQnpm!TTtMfP2;QV0Q?JR&}PNdc& zgI8Dgv*@pCpCtG~Ba+`O1c`bSDYj+(b zj!TXY9#;e2Iu%S@^T6*oAQFGy4Ek)m@XWMbqab(8?nNVGy^D+U&~|yNvw4@3wlGV@ zi^9TS2boC=D|vPIjq8%&?Ki*h-7Nc~GlpszF)C7oX;$qp0-v*my@u*&ZyXNYA8O26 z+G;J=8|<^umeDMnACWIwwSeKZf2W_=EDy@j!JQ!WP*?6^yIR}R6pNl+Tl|(FP^3oN z#4eV8*Kfsu@V5wx{na?~z~V{_SZ48hL3g|)ImzulSU7-aB~HB~K>dpjK9^hjKy$>z z^FGYWfXN;}=u<3YsP}vCtQs#v!dp`IU(c^h39kR}4^S)0Y_xEU6?=j{PM_aZoc*`qHomTZ@C=G=y$gh>WMP{( zFNv2GD+z!ah^&2;^-jgNc*`o*LciZL*A6>Y|DJ3CBXrq*d!v1haDPhMyW4Id6TVKV zDG$*tXbc^zeYtY9Osy09<-=})sqV-Zd%pK^(YVaM8bYb{=jRcsi2|zLGj%QhE8>W2_oLlbmYw!1= zplNg}Km+uaRVtD<=%47uzx1f zC$OF>#67WxInY&|Y8cCk`>hSWyx6EKvf^X+)ZR<@t)Rtr8k}usBD$l}H12vG%ZC~- zCgUyy=yl<--_P^t(_0sCi<-efm8#+*^{|y8dI@>zI+Ku$I1_n7m9}jM3_B0X9K@-i zyEy*)zl;1bcSFf8>@FmV(+bR8E&sDZF}-GE``ot@8~#AYIkwZuql?dSu-n=_iH=i_ zm7?4DW~m>(MA@EJ{`|YR4l>v*()~l=TV) zK8OjE8-)0lJc69in---+%NG0IOq81{C>|~WpVSY&U$*r>XLiTht0oI5E;mlNJpkA$Lz2J5xtJJ$kT9mJMKK9CRT!1D+ zwn+!Rw_4Li0|wDBrF&eVNIs=tPKwb{74o8931l;~B|B5Gac9HA z+V(A5&+bJ+u?)7Y0GBn7IpXIK*#9w~b2B?cXWRTVVmg7xo9qQrh=}Olr6`=Rioa3S zs?NEilKs&|r&fmMrKq-EG}{!K9ER0D{m#kR$0u<;X$? zNR$Mv68=yGx{cx)6lZ!tN(+tf3`mSgNulB4`M<{!)iq+ZGz6Yb77bJ**nh!PqGTUz z7wE5*DgV_<8QR5lR}Im)J0JJP?;89{BCv@mxC-ZBpe4)P#qj}>G~)c@lCI82E(*LT z)yh=F*eM|ssJC9&kDakG8E=@(*;9WP-K~q<*({9O@b@R-SBI=@SFI9xDxaOt(``w8 za_Bs9ZiwM8$(mQ{_pbc&zeJz0Jl#1 ziPbqHuLV6^+qZTdz$3)E!qfyDO>l@PFH>LJZp}Qx`xx?e@7*gJPub`{hYL^8@>tg4 zxW-?a7hs4=Nt3I7kquXuLEBKW8Q`!>(-$dO&hcfA3A&Ht-*Dw$0sD|Q?DDt%Q!S`(wilN-?qD|^kv`2N$82RG+kpxk_ELJVr1Ry zC?Ji>NOjW8ufcoS4spw~AdUSJ;ddtECGIcuQRN$m!*49%US+|NCHgAF>ij|0Os+s3 z2Tk~S@piA08asWS!VV{7jblqn^pZWJYb@x?N273mC2m&T7RXTD$uKZ)a$j*6rSV=c z61HEe!E))>I?7|-|J?vR3BOsS!hh`O$_8Xg;b7aXJ^TmT++Wj?} zCTuoDzzdCTJwppP_LPq>SJ8!-4=q&@g~tiJ{r#N1HLliX1%QuSAuKRyHyubm7M@k` zTz^4+E>cKyKwUSWs6CvUj&7VgP626h+VEbspJLgv19#N6|LUx`q4l*sJkgJ@@#Wsl z&)LChrcNDt2v}bE+4LR0akf+APfY|<^zI3sODeBk#p5aCHl(>eJ!ZM1RoI$i9vW~3 z3{ftHt`^F`*5RNn!aH2h-hpTyV=QW+Ug|ry)KPjKEK@jGw}v-W~MMXMh`CtHbuw)AW9az1b+@{oB8J z!+zgho+S6vummGHc{BXVz{(Gx>7Wa?lxD(ftcqP~a;Wb`J8eK=^_%k})P-&m$%Z^B z^OFl)L9_v3#XcWxR5#{8;EDftqh}N+>V#CdbZc;e(s$X0B}_;cEzluYZ%evC_SR zL&iHdguTOHrypYxKV;qF(E3UbYGdo^l^w}YW2~o1`M(JI{qD@t|HIasxI^6rZr>!N zFqMjIQzt8kWd<{2w%2_> zzvnsL<9*)$;X1DGah>1K`8j0^q(Iwiz%Qw8OWqz5vKbNvm#uQPzg4+rd0mO2xImcE z6$RP4-Kf}CFFPgR4(ZOpMSSas78MnF*PrP$ftk^D&9J}_U z?I;&IcvQK36|%*730tNB-DdBX0|+^z;Hy&3O1yvq$a&wk;7ReAK}`2Or8cMY*WV6W zpRm{r9wBYHzPzvK<~2DB`cpm!GUb;HfD$ryCT~Hhs|*q7(6Gaf1RM~qzUZu&_jR9D zJ)~|`yvd{eLq>*j7Lb;xKb8vr#3*3y5G3cWHc*K?$1w)=hZhn$z}!n-5<5U&RvaXR z>sLJP_AJO>wpQ9kudM$fz3w!qwqCD7#-a{IBk~FBL_LNaWXWn zkx%F-ZXIH{T-^-c&5RQKJy;N@_S0;Te&mhbfXs z^&yEsjpL9_g0Ei>&~mgenaA99bLRIib7-pyDUHJl`|?(+$Z$p;P9`a#R5FdW+I*cy z3vWtpX#Ym9dky}0MNY!&5RPGD!vwM~AU2F(!D<|{Gr*N{-83+N8#}-GE4ddHqP3_= z>rsOZF3FN4%(P1I0+SbOS~`Z|lfk?|i!-moUe;Z4T3e-3??OY<7Q)33y^{tOLs?8k zwed5rF4q@t!Y;kr(rs@`kr8mXLC8L6hzwfh9vI zf}LepeYeta53RMgDFIrUkKN+#F4oYi%SV&1DZ|Fhv;;W;qyz5T^XA5=SC*efpQ=@r znm5D$8XxYq+L zX8#h#xZN{T$KdphGtwX__rCG(oPjo(hXLyB$pq0JmsJVsTIp%x=e=EsvG6{WEoW z<1AZ+y9{nR5g3%8p?$bAo~!L84z-QOMC%)F>eXSEvPk@c z;+#9ZSW>=aztTP0^2YphyzhX_2c@~XhjVAVtj9d!|Gn{OF&>mwD>Ti)GM?X^@Xf{#F&rHD5a21Nx^>JI* zQvegcuu;XYB3eMfIvJZFZK095l;!5z+XL;N6?{3O>DgG@0HbzR+pEV0J=%4X z`!VkBJ?eo_->3Qk?ww6cG}EG8$(>n(AUb9oyP<%A28*{eyfNj-`z{{t+lutR|1!n9 z;GZL|gez=z}!QJv8t8*J|`T(~%JWC7WlW(B@mr-KgJh zh?3I<#P*e1Z^*|8ms;&WKiq8ya5aDxOmJujYQ?@wCI**>Sh`jKBpF{|xZ}-p0uD&2 z%7B+LyJ(1tcxEjVTcXx7A9a*W$6iV?HHn%bxh(8qykn?51T!cIc&L}JCBKrmNx#;a<7m%47;m#MU_=l{ZAi`3&vu?gg^ubRa&>w%OXilSicc$>4Bf=3;EaE8;Z?4@SZiZ zD*%UYT~m_yGGc^!;EtUy-l7BWjs9%=rH8)@1%Yi}>%OPo!~ghLjgvs$>uP#lT{fi= z#ZB$E?+pm|H+eB=yQBenPL;|x7GV6=5_TxTMH*N7IC5s8_6i3AMN{eAm>;-B7KAmS z-r?3S-FG+GT%Ji=G={3rB8&=7yqxA+|A(Y@<6(x0;+9ZKVEQ$D=((pWI_ktS<)vvx zI}e;EHEy+96|RXhHVB-NG>f@>Wl=+5G5Cf&uOsy$iUvj=pXOp=ub#sW5P>fPL(Y{+ zj{i;xffR33wyio+7>eJNONKizv{P4*5^Qfmdtly~ky)aclPeE$bo}CgO#<2k;k;`5 zTlsz)7MzG9vv_q};BHH^PZ%R@G)-(G_LWm3I!mNW3Y4 zA20OF*>3et=3d*T+W(hw*uPTo7px{aKH9bnzOE5_xo&XrZ|KdAdV^$M&8wvfD*`5x zjz9AwG?A9PjA-`ZjMBz)Xqn~qn3{!YRv8;>2Qsa4q9f^!K+Ywka{Ko?!RUS5EP2Fs z@7x(dvj&qNMHkiBWzqNY`p{v^UkyA@m$5;qBOUa#FVArks?f#xHkNlYGXR)WqDry2 zqeSxVnG*ZFG=;rWo?asJ8nK`GG2|8aUhYa_H* zqL}cbxVM)*f0ny=qJ&x?<>I}BW0(OIWVI8&z|FP`0mfB(^+yr{PUu{^UKn$Q_cw+`U#8e<^m3eM^wQF>GYRU{;cKab zXd9a-BC3D{hTv@|%63PSm)8?^1_4Z8=#vCf6nwOdXxFN1zsL4K(py6B5p1Sq)YzPD z$^J^v>91-kJzI*Q!&+kqA2m6NR}k^~;S5v+hcRODDnz9>iw)Iy>f)XSTWSE*k8&2ecH2G4P=#FGbbTY#p0i`sc_T8CadLeVhRA~(1U!+2*+0QE*1zA-FMXtF25dEiu zy}b2Nok8-AGp>1&PrBnwY0876G_A_W_JM#tAZSIVNBKo(A;Mb&vG-+p8q{LfBQW_= zW1_ukKDkdD1huu}gYV*YXP|~|lz$;)`~M3saDvglRbbSHb(ZoTYW4C~`W&$)zN1)E7OVM$FYcv~i9t>$l+rf|k`jscH?}cZ+3f zAL4&S*cI-mFptd(?r+Hq<`S{pD=YB`@T)I@?k~^RPLyd+(eB_q_u})2Um(@Fwc1@B$OoEGOK=T`WQ*j9e5kSqGw$MIFQgVSZ!S2l@TH2Cdhd9MEf zkl*l5wHKNmZ=ij?&mTPbwZlnGbNtRd;I#m>peFJk?S>V;)4cEn>CA6B*wm#9rTXa}A=k#$R#0KBuXTK5b&C7EzFQ!&hnIz%9 zBC94>PKYV-KW1r1a6G{@Jw&ExTwwd*v z?8lsfQs3t9BtHAQk)RhIUm-^LC;6jUgh(@=uDqx$`e#QK=V{d!e=$}jhW4T${yWNf-ZhZohmxo-TdptBts%uc?X>|0 zPooBDS1Q6}{|8V$wPCJ5r&nA<=^XnZR9TYm9qnc1F}nqU^kRb!sUdd%o$AiNoo#ya z8Sk`)*S)QIGMyDVn!w*|An80HpGL&1`4NV5Ph8Cj*3+Pe*6B~ej%JS^jSo1}_%AY& zVA`S*6BwE6(o@8h6Vjkw(vq;F1*sNn7FJx1&&9-=hxi@koShM2pZ3tH_qe_Yzk@e1y3SC~oa-CDK(q&xZ0JVd ze6xu=sncOJ7v~C^=tOrlQG4sX?zanM+Ha${PpPdO2y^3{+-OTE%3i8-$9N6#!cR)R zbo;*cInTzfG1J9@WYo29UFjKq8{V?p??p}pcPK0m<@pF}*AYUkce^dP9^~E2rwV_V3AwAlEyvgRhmA+<6LzFz{Q-4dyQk@ElPIDY+2V%6gI9X+(e9 z=LXJ!N8s;CgYPi>?EeQ=j#r}dX^7Gc(imL!jE0pn?-ZnYKWQXd^u~GRcU-)shv)hf zwtB81Y)S64Z0urCyPz=jw}4rn>VlLxA%jb#s z6V~;6nxbzN%I}Ai3``q3(je0VUu90O%NDN%QD%3_H)%*(v?Jt!R7;OyM!Mrb_Y*bWt?jrYcSH48%B-l87z?~RFH{Ls_d z=r4oa^s&jAA1p)qg`{1VEkEIz)D*54nJ#p${NBdP@Nuq1nr>HPxNcL`7w5I44sfs; zE7(c7^Gg42p2fFbzT;(uaT-7~Xm4+krm~pc9MtthAA4g?pNoq=wDomr4Lp-r91!xL zyeSy0eB$fNmT zXxy22YjfWqCVX)mJhyMK!(@b?CWKTR)6D?Od?E@SPyWt0@@)FS+<)+>_-N~3oonF! zmX53`op^GnW?EOKda^h*DDB#25ztHkv$^E>mzGph(vG>=E39dq z8U*b@I4(`wQ`pb z{NBT+`xQq5cay5Z!8~-mCrX9#Et%5U*G$h2Y@)SeN$nYcW2YH~Nohu-7MZyfjFmoT z=wXHx`Iy({_gUaqrJXqOIzjnc`4N?~L z*>jNyje^M|!fwjchscL{Zp1jX3as2O!DF#O!oXY~KHt|#gmPxF`K;bk)8DXV&YTar z9e>b|q3fndqETuIOid}~9ZT1q{&+{=U76s|z-QNzF<)imODJN;OSg|}7O38E^{W3G zR(7t2+R>MIWS)X^>2@yfE^AIZlcA{zgGCw*_aYGBCoLyLgSdS>V|zcOGZo1T#6G!= zSeYoPkg;^(b*ze7u5}$wZRupmO!KHQbV2dbVAu7S$B8itu&v3i7d%>vU;5lR(>$#m zk}Sq_;qx!p*25Uf_cy>fJ`zBL`8IZ!|M3yw&(-mCDT!j`o>mDRY&e=sYP~c4RtpGy zaLC>dA{KMkDk16j&pDGCgS)^hPg8Wr+ljvvnZs}o!vQ&AC)<+u8WhCD(nK!A2#onW zmopLis=U@YRi_^FA-*JLs%$)x7*}Nl=#Ffzt}zV$aOq|yi%xW)Dp2Jh&EW9Rr4{z9 z&2{u3E7u@KL?Wx|HUJ4#b`G8XzDapUPXTawAxRA){62P);R6*$ex!r`+bN8wVVL*2 zyCnv=vuoqnldvnTgD)y&=mztKU%ggeyjv&+Low$otluJR3Lk#rqa0YmAdj&#uxTn& zd~rMZ*f;@Su3&Dh6{@9+{so1VQ!`+yF9hcwH`wP#&4Nig(b(Xd*qU&lOiLSH76ppN znpGAaW))5yQIE^KDGm8;4N>o@GdyQ~L^fk9WS%wyrJz@exBF#$!#u}}raj0MdWQ1y z-IiKDw&2MdG6{UAfLLJ?*T7;4?GwtT*mCT9i7;~{5_sp<&{g=qGJ$0j*%%QJj1D9h zW&r4UJAo7{Ah`lOM*#ET{TaU;N1ecIXbqpFeL^g$0D;Bjs_O{_1_T>0*xjTAD;|8WsZ zpSZ1X0;#I`AwieDMn3tSt5=4uDidP3bGnft=B9#olDu5W&c{&RzcZk4 zE2|7oj^RFYjO2bPfiEUn4OdytM8*8~9PP+f|7R~YG*zSF0h#|RhsRc>daEd2(p`Lv zMV$ZYYX{|mzCKf;=sFKqI)A;)y2Cx`stiQNJ7Y3&VRvdHkr$KyfCc|>{P~kjv+2j- zyIge#01DDnM9S#kz3nzu;OWR(pF_!pg1jI-V(UqyzsURsH{O!*VxC~WopzW$;~WE| zl9ke9OhPoQ(U^CEEj@`p$mjD=P_qDVn(6k%aIY)(eR~aCR&K6k0}?F{(!zcfOltwm zPC|-6!fE&Ue&#u(iTt?%M9!-Tymi#3?baUp=Ce$8KzM;SbKY~QqA|EzPWY4{w=E+(+=E3OC&AHEH+GI`GDqz()4y|*Y9qKx;qOHq8iE=!v^k)0?a)2kJO5?rQJ zLSD_YV~GoW8SrH#1Gu*qVDYzCAvHSW;}k~IET3U6I@9uJ4Y9^>s$@1@8(J_LnplND z_)CJq!*fTvweaM15<&|nb)q)`vAWPVcUA-5&yf4QAe9Z#HQdcHc2IsS&G2<~hu*VO z3E0*qBjfMG!iH*Mg2ZPtHH_Z$5nY1PMDMrrUeW&|rH55vsYSuX@A(XJJQ<`-er6~#$Ux`u z3(Me{i9F)1K2LyN{mxA8yt?i^3*NE3ZwBh(%p5f}#^(?1FP@Tf50fs=!UUV|HY(+o zuFQ(qk^2m#Unq`GqBu^7DG|~yB2g7AN?%+W+5d+5&V28hpp{a~ zk)xg8@#Rf`3wZub5&tKy(czmr(~k6OCr7wOj;W-l)#N!qXQn>F>km>B&0*vW<- zbx%8i9?~UhwY=NccYhjJbbOI*MIc_MeW6So3?GNfRvKP^oBC|pM`5k@SA(p7B6|i# zIkwjoU*QH@Q3mDagd$BFPJ6Q^OQT@>;-VTnI304D))Up)%(kD!%eZLG#aqAo6~8(J2gnO5|-Vs*~nDlEic#$IfU{& zwZdfsL7my{bv@z0`E1tZSo){Ms54@^&o)M^)R^qVETj}eC2t@2xSjWO{M*Fc>Ie=M z`G_SZ$+(t>IBpzxExf%NWs$TY+Zb#CT^TdApcVy|7e0R*k5>(UKu^KKMIp0YzGc4w zy_Wc!^wau=CvgRjV$Lh#sl>Hc^QC5eB%2D6|Lp?Mdv2scoMy~z-nALv+|so-gk}GL z{!k~mem2{G0bS|Td)oK6YY=LQ2EXR3%Uf(_HU&1);!#T;?u{U8)M@CEGjnz=h3t%7 zt-+9I=|0S7JY#v#AB;a{*?s5H^wtklsw5UTPWRe;_DjNcWlFtwxgu9Q2}7qxu*D+B zd#>x3JwXLt`9Cb)-@bhtP;}$NQ4X&fj}^&^)EXfVFiNr^kDmhe|D(2zIY@vMg{}N z#mg{PfpE+*pcP_JE%yDwP)?1w+=lM;%6`DH3m~9VD&lYH2y5Xh+~PtAqx(35>CcV0 zgV5rwhzE6p;a5M~tWmU)cYhaB20RNz`A|+H+lv#Dp>yvJs9l*N}bIA=e!O|^^2NJerq&)9C`9z}^lAen- zLT=1oQ+GIB0gYJT@qF$%8$yLhbw~XzK6viEX93~ne2Nsl8Od6Ll@0_eTJhh1r;5H$ zW7I<9ry;rC!1lkkjn&ce% zN}|~4Dlm$)1{8eFC+LB*BcAc034J>(!X^W{q2+v8jtHFHOX_cp=Cepb#&`!s;MHqG zd4P>1opK`we$U+vJ-o7z{lvB;Z-9pL z5txvY2zdQ%U|dV$a<>T*BOr0Sscd}W+1pQewXwhh8}DWUCRjTBl^Z@_s4V%DY*P21 z^TCzX;OTT^80+{0o7~aYivH*XEaM+si~GILf3hq4>V{qH+C^Rqhk8n#y2tqTg(-5@ z_IY82?Y8 z)b&{kLpg@J==$nP>z8+a?-i|fvHiQqA2Qp|pBj|mm8tW8&=Fy=m%-%OXzrB3Z=z5* zFFXvx)kP|-vY!rKKu#hk1%`7wq&ZZ;Ytq)i_cj2t$A}CvX>IU?&NUWqB2H~ky;jh} z%g7!V(BxjnQ!wNy3cW!2o{ua-d8=kDSGb;~ty6*F8Qh}iy;=3j8}01F>`T`}Z0*FmegaJ%ggeC@@WC@x~uB_7XMhIW!7CuZx#m|p zH6tBHCl+J%zKpL2T9QN%E#VPjsSCQ#LrHQfXJqwIit!-cn7_2eR--pA_eDdt$z45P zE>Rx-u8DDDP<-_ZxxIw3`pHwdC`{Xh)>aRZf;7v~B;aRaaT1|!w_3Lflj9YZ z$5qL+EB-w>oAGi9f4oHeqE1T5a~r+!-{+!96ky1$%!Y;AVk7Vwv5cTG^~qn$xiUPc z>!WfMcFYw_s2$**DXJ(CPSO9!6=A!@ACcvf`BdZ=)K|iG$@O{sYGkZ#5nbK|C?0 z20gVKA2d~%*Y8*5a)y4Ee_d~IqG!zF7zWkaIWn?dR?|7g#+i{OCGzFvM@n)fkRu7h zy$p|i-OP^CIoFVwI+YK%D5)=rO2R27WgCXYhK5IgrbH9Z;90I7dWH81CGk-H#@Y1} z$?jVa7nZGZ6^>Np{SoK2gyC~B<~r1JvNO%D0>X_o+>bRlmJi{?9>Z5nL`VJ6&m#rD z)`(OYl@bj{{gp)8!ZgxO``t5PzVv$QxHyD>-@ML-C?{cPQ6tA%-%uPgeA9&uo(DBvGV~n9 zRgj39pB7F3$fPSZ5qS)n? z?y2}JG+%q?`A8VN?)N zzVg26hpx^)+o5L0eWx8qGGA!&{uTo4$vfUz9hzp$Clv{46m@MjszrCN&Q_dAma7r* zwv*1dR9XQ27#^uP=YAg(I;!~C#=_N;nt%kp&L4Q>7U&)RNpYv6vpxhWV2{?aW)~Ut z^&9@B0V=lru>H&UWw>-9Y+pRl71fOJ!whoY-^@XAx(RGi*J97QgUgS^C>w%QZFTjT#epa%i{vAyV z$vBJwl(+tzQ?AK?%}g)s2&g<7M`nN)`yM7|Yzmh^yi==tqkYn=N;Uh{x7>rDF!W;L zb!ADvqFj&L-xUcpWJYXXhb^b|GGeZf*+>F~1 zJaf2C>(Ystq6!Pc_g`y^y*7n2aaW)_JVWg)u=S@q+h_Lg=ZXJq64*CZn)vo|In1cq zGV47!i_8XAvu-e;RCyiytNo2OttBK{Vm&xFPftv;Wl_wyuQ!>d5J@Ea2UN(LBUyH=6s${^fdyP}7GtuF_cI(J=Tnd18CR_P<=e zQruK9IVHNHBk99Wzcup%gcau^o({Ntf9TBPvlC7UJqn(c17_?ExUQ1ui@t%$!e_)=J%2h3fR``q)dvQp#`0{m}RE*_b$&#O8TPTr)Dx zH|uMuVpDGU>Q%u&!n4Q?PN8v9((}H$KR$yZAMA*{E@K}nbauuRcgrAWdF4OujjE>` zsONMiE8S|FL>(T+p9xy=*7z4Imj~3=@JG%n*gk?@7sY~9r`wemh5$bVVt%$cDQA5t z+mXYG=*1SEIbuKsFV4^IQ;Ru{sA-Si6c&vDLqkd;-DWNodU=NXxUtOWJj0a$ujB&E1^)(wdQ{p?@;M=*z{{N4|1JsI}Ke zI$pc}`|K2x4C4HVh0#fkx(gc8e><9r(0{maXSeR$b+Th>MyS8uy3R5d*B?B&#od1n zr7Cd_$@cVT4ttlGA1*oPPlV&_%2v(38u&%W_e+g$h&q77`44_|zwF#ivR5FpC-?dh zX;E=;0@wq;cSgSQuLIBP#2|ylJqD*rE0Tz2jIpxjJ!Mt)JP)s#?K_{3vAvVm&kXNG zT(9&mvtXFrL24*06vKr!%-o<4Zl1%-WLgAGg<(p)TvDKH z>7PfwrLmpjX`RX1pF7RFs%KU8ICP3n5;_tIp6%~3xqG5U1eNpI5AO&Cwu{{WgbL1l zSFjNcTGJsnp0VcBt2_Bu-1f~9)ID`9cV2AK2~pNj3$si+QX_Zwk$OpG%M$ng3d4rc zfGVnwrz&=P;=7PqUG6^jxSXS&XM4U}qQb_%RiJI=#c+vNMMsy_0b|_uV2&L1ucF3s zcfaPFT3<`LY~I`?vXZsNU3HGQ+V^!lXqC%{yP<`8x9T^qI#_(;4CgWhNLq0;3@EhP znxOpxf2ot6*WGlG{&e!ZIECiZx!yC)k$=BdCq^{Sjt523LGb6OztxI~OcQ!V-u|E! z{1Kc$D&T#3!)>7h?Eh;*?FOs8Rfz(tS{BU~!_Mu)S9OpSis=nzQkEF20y%+PeCqT&?AsL>K#WRnjAX`$&E~&R$%Dw#s3&T=D z5r8YVYK32s5-QeH;x)F?&abc^x24f`78O9mjM>fbNEr+rq%Hth>A85GnTIY+t0W5U zo)ZGD)n@uX;UJ$hZKuraR-su_MaUDh!e4>1nF=LiS;xWf?CA!6&$EdW#~;U~z##qY z@46${2X}Jx?PY6q7OygWaJzp|yKDo~P7(x*yaG-q>d25gpFIW91zjWvaz7ZJbIAn zEX8prQcQC#_)KsD<9^J8PDBBex3R&noB|kcau^EH`hne-5RLZa?0U?P+;E86Ws8PG zLzRKSpOb2a6q^=e;+b?#WMyWEOGX^cd#ST;FLIS_j$t4W2G{{QSC>^Uc_@r zt#o}3=cEs1weZ2>yvrMt#7o~cUV^hmEWb^hd>KSOV(t6xw&9eNco}cn(}@?1QxJuE zIlQ@OZGRc}40at(c71#K#Xl*f zalC(9ar>mox^~xZNNf8@(XJ%hcPqYa)kcA!|5AJA$u>T{8Xi%Xr2bj%n=&{J!PNtb@Fh8S(nrQj&n#t1NwfMq*wX{gif9BGNM)g z#x_2*Xx36b&@qz8q@@o8aslG2{x(82AWAuJUxQ8l%1{#IG_5W-B*kx#&2rO#+N0w@q|HaO$4se?mfjWr> zeNZ-)ew~~W5LBdAKM9!Bp`#Ay{GCk;9T7FoFN8cTcEhT@^tSo+Ad{i%kaf(Ri#JVB z-K}R$87Jy5b#&(A3MrPoL(IhCnQ#$oy%f5T1V ze?1082#@o-u@2~M&6nv}bbl+n;)e9UuA|vK%}xGS30%r(e1{H9=Ps8sWFzEL{s|rd zU!Rs2O6L&>3%#yCvvDhXvW{J8Y!8T{Gg4l74&;jjII+-G_$Bc4V`I|1YO7{4UUR2e z=6J1er=TZZQ>1c`Ub4Jd=9iZ>8-MxS zjCzD2Xa@S8Sk3$*_XceGSSe^Q87WSa z{-J(H3%z?-_$|@X+V!@-WKuke;bzKpmzF~iCnuEg+bWr2AC{>?Lc&JVF~FCUt%#pG zL+`~2uUUD~CdmfVuP_q2zCM0i7g9t^8KZT+CYYf$V|}N)D&q3AM79Js(L}K3`tn&d zXW?xI3=DL@aE1zYc} znV(rYIL6P>4*%lk_CEgyKOb-H2FPg{r*(UU{Zpf-u?qGc{JbYZTw0uXymPQNGn9PC zPXlKP@98UV7-atJYM{rXh+atp)c=<;qMq};O3s(RXXO*Pw*=jzKj1da3EcJjyBbTj zoSMq|2Nz$3?JY;-PJ|4q;KcvG`1#{5?R|AVl**E@1H_t_W2;8Q9R`sFXLLqVMrvDh z+j2f8k79GbTTqhlXywXWHBLV8Hfxm;OwwjO^5m1h2mU)VH**&w6VXdvZ+6(b`A@y$ zBaKKLkEn{?FNwK)8P#qKe2|lzx4x%y-yQb);(r7>4=4t=_|E@6pV~Tcjm&x0$+P*F z3^~Zde2niO3V*{kxT`U(Vz5}o*G5riry#!FBwifk&7uONE?@2ES~N_I`y%%jrE-R) z=znRf*$9t3cS4d>laGi ziaKq`pD&>5?eqyh+a&0&*qvN*(r>lV$K+<140j*jOyq9jAF|{QLR+MnHskIAd*zvJHG-|s|<&I_JHNivNAo+nh{+ba0i z<}Alpy2X-wpZePqb` zdO?l$eIXWZ+WgZCyF;(<}0AF4gs zzB=w^?-(fuv?!n&+TtYsk#I1wue`r%+gUY@vWxLA9eWON+jIuWcLgz(oz%U5-b8ql zET!b2B=53Cw^>ikD}2)XttQgG)Chzk@8tfUmHA`cS~qUw7%HFOTA{t&u)ZX8NZ6t2 zh9BD)1g3|VOahpm5Gw)2E)?|6czM3lPTItBXclmz?dOz+pO|3r9{!SkNGS2{V?W~DPf%Bj>CguP| zL@{25tfyr$Al8B~J4|*BOdT}?^akKbofr!11ApaT-u}Mn{c#ZoSU~z$rw3jO4LD_4 z+EnKYNvTc!LmsR#<+Q0}t-dr>=ePX#(Kmy^cIEKh&Re6dQ6&Sgo}8I13v<#ey**fT zIvL+qTRvz24ozGtN@|)qZFqPnX4P*@4KwL%k{@sn+^@3^75YX=6-U>&8}KfiIuIqg|mg!EPwct?;cF%hW89s<`mXn8+AfduKqQl8L|$ z96F&=)6-gY?oL3+%J*!kkHn`(E|TsyAMt=j@>yV`o61h^vTKgyxW)b{_n#fs$iU?E zFBGxi7fJ=e+M>t}cy*pW@|I9v!9gHT`SN&CIUxs?-^rb+LnZ^wzbhi0Tf!C{rsl%h zO}?RHgWbEl0q>Qp{PG4NS%=driAbjN!ETipoPHO*F_+FXg#X9y{_F?+WTUEH4YY@tSsnYgE~#JCeo|l|19cfIS?c)Vqv= z?_9Jwsd8R|wb7>{JCe<6+CS07{aEmaXu39#=kdCGDoz6IUJ%XCv8!W9S8JZLQo);b zWq-7uxtEk<*uNp+tb*BMtIHGQ-3T?BRcoB9<+S8ove%YRLmzs2EN@y>838Kx7U!>O z1`gP}qrD@HtE1a117=P(#CQn3C88&Gzr1=cHgepKY+D2dJnmNey}jneHxo4W{y`n z|Id+#%?-m1F!E?gSzd5VNJ!*xyW!FrTWzP)&Ln(8;vL{dB2Hp_*Q%baTTp#kp=;@M zk2WLTK4+Jli`u%n%!X~l6)*%58*Ljv7R~XjKFl(~YPf(OQ5l8|YAyADGj}F9tkDt* z;c1qURP{|l$prg~V^@@t_*14*5n1gCsH%S6vI^yks@x6y%+{l;128$Ne78E3_FZAB z&e^`+BbO+41EvIm)}TGPVMu{rtiv<_Y&{{|&DCq0hSz&^IuLeL3DrNNVno?`shy)X znz@)cww(4)oo#N~*FSH)3^bsy(|0A0J$_!o!UExy=jwgPK6mk@<{xo8$Jl%2*!Rm; ze-*FZ-!|)h2`ZU4q;K4V-S!{o%OH|_gMAzVLk|I!nqqoR5mv~}9OF=`tNhW4m2rgz zJK$k5ADuOn%ZN6n-2jEEpsrVIK#dCO@%)aErkE8BvOMuB7ucS1$zYH~E^!>2?}izG z$0Kr;==jZ1@>&>X##syzs#$fE%Z0W4oRgfS0#Vvlh&So_p+Tu2v`3Ba*k!mPc3|o8 zSDDx&A^6^lpe*ok1d2{#?D>6MTW{ngYSv8O^GsFGhbzNsLirnB3hpL;8tA`>L)riw zXs7l2a~pF5Vc5v{WLR`sv`mbO;fv{87qe)u)6`#a;HT07E4#~Mr+|i(v~HE+pPQ3G z6a-5kfi%<$*yH!w^}^Yd_Swp+HaqitKrrCtyCnXsQ7fq%-`7LY| zixoPB5sXHtAt2DHjkR+c+Yc13KN%QUKDx;9T!=^5X0?IxA@+Eq3ztBfV4c#*llRnw z;j=P5Bu4C^W`aTFa@h~deSt)K#Di8(#&QgVn9Vp&{^kz=EXnksWc4Pv!*C~|A)PnI$iSxT*XGLni(G)clU-O`mRSb>gcvIHCVHyC z5!iTbJ~-^Fq9>uAe}4Jxf-*%$6S;In*b`w`7&F_WFAO@&TKf|x;h%c%B73H`$b_0s zj3dc#=-yHiW-?sKX>>$#uBoc_$v!zQt+-(Tn_KW)RAFNG#FU1sZs;Yo$F*8U)M#bn zM?A3@%xMGp4eo#~T%}S@=YiZSz>JF6CM2mb%~b#XxaU8gf`UFKpAf+R?FhX**9dZ~ zO*`@Iojd z7|qPxwPMDk%xR4s>9DB2W8%OYMwWmbCF@CGJJ4f2W|K2DzYlbwlxJOLN3S)feasOL zUL@ejO9C+YJ>k@wcz-V7r0 z+25Q95)R33D3K9dp1ZK=j0*F;G)|vgh{@Q2j-e$C$YSAn!YzHzUAfc4y4U6Q7UNB5 zl0SQCi0t{;5G>bjbNNba$KGUkBY|Y-sUPhIWLyf|dp-~Qdb~bdLoB(w(>0wRs(<3t z$?OE>!%*U>eNmH)kQb2CrP$DH0blB&`_=9Hw9Rv+-eai-K6{0tn{_twTsR&nlW33X ztR2e^YzqgEG|lOXuiM|8?P4B3PmTGXCMBOM!gkYYGJ_9SkXcd-{VPURD?_2Xha#Gd ziv_<5h4iEbmtwrI#2Iz1W`-Sb9cb;=#{d#7eii%@5CqM74Sv#JvWcO&Tm(GMuEGg7ayio)(Rxf@K%^Q7MUGY<(GF-&IA>bs@G34MpQ$a0pU(2zOMjU4IM`Jhwfvl)W~&|z29ZBysz+Ec zBitW0cI+N;DQ(8=`_gMsgghe@lM{}W1rLw=&BG~ih+GAy-(MQ~=qFMpgg-foq!m0F z+f|i$P0O{aYH9xN{@3MP4Dq)+>=^*g%r#2J>2!U|yr8{VjyUCJ2d%Sn=b`q9K{i)F z{^BV?Twit|WL>zdwPc(Iey@^KmgK_NhKz7ihOnq}p#|~GkM}}Yiw9#4Z=H>*t9ZBW zX|))%4Wu}C+1eAp#ynXoKBzE7B2GT3s*pYY{oXGRO_nk>wOO z_RrkFq>Qt2;fX%d&)A}_KW_=PPP~nRFW$BUz3UDE(ll=QzcHIx;#?$1P+w}6e1Fyd z#^1?#Oz675pWCft(-67{uot!UmP3NsW)Uf3F5qU3lzDcq+c)LgoBxNfyZ&qX`~SyJ zs3rAtvlq@-)pr%Fhv7&Hh7D9u1(V-t~(R@5O3CW0bLij=@cPa33ijGE-Ab@1i+ zdcEJD&-eS&_g^@dv&Zh|oa_B||9wQvWlh6P^gUzOCI;Vr1j&ASxehqgyoFig7lgDQ3b>6Q!*23?;Hr>Jc z#z^oBl0_Aw$>})sR8Y=N91$^)yrb-e`wgz_>Px5nsc?pkNb;>UCSGk_pHYj=pfvrc zQ7@B=yb(8o&0!g8arM2nQo{JY7#*4ZlR8%&lzWZmG>kkq_447tx3lC|ljU!h z4XgO1_-W)uw?NLTI?%{9}|nyuwQjW$@MY zv`OjgVH-E-V{EX>MfnP*Qs^3Z$gD4MnZDVTO}O{tPwb5LEo0?r#3$O+1$IH-`Hwzv zAT66szU1lzkE@$!D=7J{b`w+P3`Sq0G#VzHAU$(ARx0g{goI^{ZQ0)U2fo*(BlUr-0r(;b zb6``8X4AqCYT?;?u~E6Nrwx5K_A!KUGDE`1TSrt;AI|6SgXx5-_kOu2Ams1Lcdjoy zV>;u8r7(~dd-BAc4!N_;dbB-6q6#rrdb!@l$5i0l#v=GAd6uC&lfI-TqqVg{6!$)V zS}qs5>H?PO{n_D=`W`-3eK}0{$J85x;$m$fk9lsp67TU3za&xBJI@s5-7JmIJ-OFV z1%c8YAw@Z>Zk4-vdCoSsJlSl@$z*O%r;i9IMeHuIV8xx1$I9_V(3CQN zp1djftGD$3xkXQO0?O**s5|=de#H0V-M_Ru@YJpFC|kks3M9k-t*US@o`L_4++wO<(1AMEZ`RV$yo>Whm1TBYSNva>oO+yhz~2 zSyETtmHV}SyIFq*PeO7(gIH!&1IJoG&`8ana}#dPaL$$UF^fwSq4Z&b-`kEl9(G}* zRiNQEo#%P2apd;jfM7yM&&tADe8;LRN9n87dUEFp$EC*O=->IxHZ!+wocX&M7}ZGo zguki2Vl9?_G}&WFs5MPh?G??0a_Yu|bXvOFQ^xuC-zK9rV+f5In{lJd-VdWE9djB^ zcvB-Iy$=w9kaji}CuBE{MT!@xUY)0z)zE?sEqFOzD%ahxlN>({9~3@!CRT=zmY)xwCD?OMbnd zE~qQ}kgV$p7^4J1k;TeG(=KmvpjD4`IRpHJGy?i6!E6a$Kk8ghpUer0E9@?VS#ns5 z=?7d~eer$P-kN!2Zt?WbY(CMu3S1d#!2Blfg&Fc>)!EX2X7;<_d*(Fp!xDPGfkon^ zCX%lhY2DXDp}d?w&S21!OVM;7_xC$t(?}FB2t}*6xyBgf@Q#aAT-q}Yr?_)GPqU}qT+Rdz|=?@gksIYuQ zq`m{}FSwg6IKJe|XS8b%&23`&VtLM-@CLc+#6|pP2y+QtL z+J%GJKlkJk_AQ`sC!TzsCqwOAf(%xjgk+zK)srD_u^_p6)$!@DO#;^?ayIcl_ZwTC zhBf6wa~E-CPF?}IxVjJ3-5n`ru6G~APe}nO4Z$wI)2KgoKj%G^g<^i~ZtDaMFm8f( z)Ku;@>xkw5fINx5?0nP1!i)ca=V`UZ`UdX?*nzei0S*KQ%Yd?+odM}OJ3 z$n7)q+IZ_(vJujVaW`#t72V{}0Jgb8^hO?*P6+L=hm|Y@;REF~Vm4O_ls0_+?Zy?g z&!IAdX;!7}1n7^#){Nf@nZftNM~Tm+xg{@0#QP@Fwv8C&P@QMPJ5H<4CbyR*y9@#1 z(7Z~2%wpzOAho*GAo%+TnlPpEh_^b2M}{^t-ZDoYU<9(ZD!1Jh7Xf!R26i~>*h

  • m_Vp0k%PZp)S`B4R43bOmiT-?F*_P}Tq~-|cbof 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 z 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

    5X`CxWWLl4Rzg~8adYQPSbQ$^UNJ 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

    , + ), + ); + Object.defineProperties(region(), { + clientWidth: { configurable: true, value: 400 }, + clientHeight: { configurable: true, value: 200 }, + offsetWidth: { configurable: true, value: 400 }, + scrollWidth: { configurable: true, value: 800 }, + }); + Object.defineProperties(image(), { + naturalWidth: { value: 1000 }, + naturalHeight: { value: 500 }, + }); + await dispatch(image(), new Event("load")); + }; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: typeof resize) { + resize = callback; + } + observe() {} + disconnect() {} + }, + ); + gallery = vi.fn>(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("counts rapid clicks, changes cursor, cycles with Enter and space, and resets on a new image", async () => { + await render("panel"); + for (const [index, percent] of [110, 120, 130, 140, 150, 100].entries()) { + await click(image(), index + 1); + expect(container.querySelector('[aria-live="polite"]')!.textContent).toBe(`${percent}% zoom`); + expect(region().style.cursor).toBe(percent >= 150 ? "zoom-out" : "zoom-in"); + } + await key(region(), "Enter"); + await key(region(), " "); + expect(input().value).toBe("120"); + await render("dialog", "next.png"); + expect(percent()).toBe("100% zoom"); + expect(input()).toBeNull(); + }); + + it("commits valid percentages, restores invalid entries, steps by ten points and resets", async () => { + await render("panel"); + await edit("245"); + expect(input().value).toBe("245"); + await edit("60", "blur"); + expect(input().value).toBe("60"); + for (const value of ["", "bad", "59", "801"]) { + await edit(value); + expect(input().value).toBe("60"); + } + await click(container.querySelector('[aria-label="Zoom out"]')!); + expect(input().value).toBe("60"); + await click(container.querySelector('[aria-label="Zoom in"]')!); + expect(input().value).toBe("70"); + await key(region(), "+"); + expect(input().value).toBe("80"); + await key(region(), "-"); + expect(input().value).toBe("70"); + await key(region(), "0"); + expect(input().value).toBe("100"); + await edit("800"); + await click(container.querySelector('[aria-label="Zoom in"]')!); + expect(input().value).toBe("800"); + await click(container.querySelector('[aria-label="Reset zoom to 100%"]')!); + expect(input().value).toBe("100"); + gallery.mockClear(); + await key(input(), "ArrowRight"); + expect(gallery).not.toHaveBeenCalled(); + }); + + it("hides dialog controls while wheel zoom stops at sixty percent and can zoom back in", async () => { + await render("dialog"); + const fittedWidth = Number.parseFloat(image().style.width); + expect(container.querySelector('[role="toolbar"]')).toBeNull(); + await dispatch( + region(), + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: 10000, + clientX: 100, + clientY: 50, + }), + ); + expect(percent()).toBe("60% zoom"); + expect(Number.parseFloat(image().style.width)).toBeCloseTo(fittedWidth * 0.6); + await dispatch( + region(), + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: 10000, + }), + ); + expect(percent()).toBe("60% zoom"); + await dispatch( + region(), + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: -100, + }), + ); + expect(Number.parseFloat(image().style.width)).toBeGreaterThan(fittedWidth * 0.6); + await key(region(), "0"); + expect(percent()).toBe("100% zoom"); + }); + + it("fits the constrained panel, refits on resize, and keeps pointer-anchored wheel zoom and keyboard pan", async () => { + await render("panel"); + await act(() => resize([{ contentRect: { width: 400, height: 200 } }])); + expect(image().style.width).toBe("400px"); + expect(image().style.height).toBe("200px"); + await click(image()); + expect(Number.parseFloat(image().style.width)).toBeCloseTo(440); + expect(region().scrollLeft).toBeCloseTo(10); + await dispatch( + region(), + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: -100, + clientX: 100, + clientY: 50, + }), + ); + expect(Number(input().value)).toBeGreaterThan(110); + const before = region().scrollLeft; + expect(handle.current!.pan("ArrowRight")).toBe(true); + expect(region().scrollLeft).toBe(before + 40); + await act(() => resize([{ contentRect: { width: 200, height: 120 } }])); + expect(input().value).toBe("100"); + expect(image().style.width).toBe("200px"); + expect(image().style.height).toBe("100px"); + }); + + it("leaves gallery arrows available without horizontal overflow and pans only while zoomed", async () => { + await render("dialog"); + await key(region(), "Enter"); + Object.defineProperty(region(), "scrollWidth", { configurable: true, value: 400 }); + const left = region().scrollLeft; + expect(handle.current!.pan("ArrowRight")).toBe(false); + expect(region().scrollLeft).toBe(left); + const top = region().scrollTop; + expect(handle.current!.pan("ArrowDown")).toBe(true); + expect(region().scrollTop).toBe(top + 40); + Object.defineProperty(region(), "scrollWidth", { configurable: true, value: 800 }); + expect(handle.current!.pan("ArrowRight")).toBe(true); + expect(region().scrollLeft).toBe(left + 40); + await key(region(), "0"); + expect(handle.current!.pan("ArrowDown")).toBe(false); + }); + + it("suppresses the release click after a drag and shows grabbing only after movement", async () => { + await render("panel"); + await edit("150"); + region().setPointerCapture = vi.fn(); + region().hasPointerCapture = () => true; + region().releasePointerCapture = vi.fn(); + const pointer = (type: string, x: number) => { + const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: 50 }); + Object.defineProperties(event, { pointerId: { value: 1 }, pointerType: { value: "mouse" } }); + return event; + }; + await dispatch(region(), pointer("pointerdown", 100)); + expect(region().style.cursor).toBe("zoom-out"); + const before = region().scrollLeft; + await dispatch(region(), pointer("pointermove", 80)); + expect(region().style.cursor).toBe("grabbing"); + expect(region().scrollLeft).toBe(before + 20); + await dispatch(region(), pointer("pointerup", 80)); + await click(image()); + expect(input().value).toBe("150"); + expect(region().style.cursor).toBe("zoom-out"); + await dispatch(region(), pointer("pointerdown", 100)); + await dispatch(region(), pointer("pointerup", 100)); + await click(image()); + expect(input().value).toBe("100"); + }); +}); diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx index 982b6e67b..668801529 100644 --- a/apps/web/src/components/chat/ZoomableImage.tsx +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -1,3 +1,5 @@ +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { useCallback, useEffect, @@ -8,6 +10,7 @@ import { type Ref, } from "react"; +const MIN_ZOOM = 0.6; const MAX_ZOOM = 8; export interface ZoomableImageHandle { @@ -20,19 +23,24 @@ export function ZoomableImage({ name, onError, ref, + layout = "dialog", }: { src: string; name: string; onError: () => void; ref?: Ref; + layout?: "dialog" | "panel"; }) { + const sizingRef = useRef(null); const viewportRef = useRef(null); const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); const [windowSize, setWindowSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight, })); + const [panelSize, setPanelSize] = useState({ width: 0, height: 0 }); const [zoom, setZoom] = useState(1); + const [percentInput, setPercentInput] = useState("100"); const zoomRef = useRef(1); const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null); const dragRef = useRef<{ @@ -45,10 +53,15 @@ export function ZoomableImage({ const suppressClickRef = useRef(false); const [dragging, setDragging] = useState(false); const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 160)); - const fit = Math.min( - 1, - (windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1), - maxHeight / (naturalSize.height || 1), + const fit = Math.max( + 0, + Math.min( + 1, + (layout === "panel" + ? panelSize.width + : windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1), + (layout === "panel" ? panelSize.height : maxHeight) / (naturalSize.height || 1), + ), ); const width = naturalSize.width * fit * zoom; const height = naturalSize.height * fit * zoom; @@ -90,7 +103,7 @@ export function ZoomableImage({ const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => { const viewport = viewportRef.current; const previous = zoomRef.current; - const clamped = Math.min(MAX_ZOOM, Math.max(1, next)); + const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next)); if (!viewport || previous === clamped) return; const bounds = viewport.getBoundingClientRect(); const x = point ? point.x - bounds.left : viewport.clientWidth / 2; @@ -103,6 +116,7 @@ export function ZoomableImage({ }; zoomRef.current = clamped; setZoom(clamped); + setPercentInput(String(Math.round(clamped * 100))); }, []); useLayoutEffect(() => { @@ -124,6 +138,40 @@ export function ZoomableImage({ return () => window.removeEventListener("resize", resize); }, [changeZoom]); + useLayoutEffect(() => { + if (layout !== "panel") return; + const viewport = viewportRef.current; + if (!viewport) return; + const sizing = sizingRef.current; + if (!sizing) return; + const observer = new ResizeObserver(([entry]) => { + if (!entry) return; + setPanelSize({ width: entry.contentRect.width, height: entry.contentRect.height }); + changeZoom(1); + }); + observer.observe(sizing); + return () => observer.disconnect(); + }, [layout, changeZoom]); + + const stepZoom = (direction: number) => + changeZoom((Math.round(zoomRef.current * 100) + direction * 10) / 100); + const cycleZoom = (point?: { x: number; y: number }) => + changeZoom(zoomRef.current >= 1.5 ? 1 : (Math.round(zoomRef.current * 100) + 10) / 100, point); + const commitPercent = () => { + const value = Number(percentInput.trim()); + if ( + percentInput.trim() && + Number.isFinite(value) && + value >= MIN_ZOOM * 100 && + value <= MAX_ZOOM * 100 + ) { + changeZoom(value / 100); + setPercentInput(String(Math.round(value))); + } else { + setPercentInput(String(Math.round(zoomRef.current * 100))); + } + }; + useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; @@ -143,101 +191,177 @@ export function ZoomableImage({ }, [changeZoom]); return ( -
    +
    + {layout === "panel" && ( +
    event.stopPropagation()} + > + + + + +
    + )}
    1 ? (dragging ? "grabbing" : "grab") : "zoom-in", - }} - onClick={(event) => { - // Pointer capture also produces a click after dragging; leave the image zoomed. - if (suppressClickRef.current || event.detail > 1) return; - changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY }); - }} - onKeyDown={(event) => { - if (event.ctrlKey || event.metaKey || event.altKey) return; - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2); - } else if (event.key === "+" || event.key === "=") { - event.preventDefault(); - changeZoom(zoomRef.current * 1.5); - } else if (event.key === "-") { - event.preventDefault(); - changeZoom(zoomRef.current / 1.5); - } else if (event.key === "0") { - event.preventDefault(); - changeZoom(1); - } - }} - onPointerDown={(event) => { - if (dragRef.current) return; - suppressClickRef.current = false; - if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; - const viewport = event.currentTarget; - const bounds = viewport.getBoundingClientRect(); - if ( - event.clientX - bounds.left >= viewport.clientWidth || - event.clientY - bounds.top >= viewport.clientHeight - ) - return; - dragRef.current = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - left: viewport.scrollLeft, - top: viewport.scrollTop, - }; - viewport.setPointerCapture(event.pointerId); - setDragging(true); - }} - onPointerMove={(event) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { - suppressClickRef.current = true; - } - event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); - event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); - }} - onPointerUp={(event) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - dragRef.current = null; - setDragging(false); - }} - onLostPointerCapture={(event) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - dragRef.current = null; - setDragging(false); - }} + ref={sizingRef} + className={layout === "panel" ? "relative min-h-0 min-w-0 flex-1" : undefined} > - {name} { - setNaturalSize({ - width: event.currentTarget.naturalWidth, - height: event.currentTarget.naturalHeight, - }); +
    = 1.5 ? "zoom-out" : "zoom-in", + }} + onClick={(event) => { + // Pointer capture also produces a click after dragging; leave the image zoomed. + if (suppressClickRef.current) return; + cycleZoom({ x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!event.repeat) cycleZoom(); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + stepZoom(1); + } else if (event.key === "-") { + event.preventDefault(); + stepZoom(-1); + } else if (event.key === "0") { + event.preventDefault(); + changeZoom(1); + } + }} + onPointerDown={(event) => { + if (dragRef.current) return; + suppressClickRef.current = false; + if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; + const viewport = event.currentTarget; + const bounds = viewport.getBoundingClientRect(); + if ( + event.clientX - bounds.left >= viewport.clientWidth || + event.clientY - bounds.top >= viewport.clientHeight + ) + return; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + left: viewport.scrollLeft, + top: viewport.scrollTop, + }; + viewport.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { + suppressClickRef.current = true; + setDragging(true); + } + event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); + event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragging(false); + }} + onLostPointerCapture={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); }} - onError={onError} - /> + > +
    + {name} { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} + onError={onError} + /> +
    +
    {Math.round(zoom * 100)}% zoom diff --git a/apps/web/src/components/files/AttachmentFilePreview.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.test.tsx index 6bd25ce31..e5d298dbb 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.test.tsx @@ -1,11 +1,14 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; +import { act, useEffect, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { AttachmentFilePreview } from "./AttachmentFilePreview"; -const { refresh } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>() })); +const { refresh, pdfLoads } = vi.hoisted(() => ({ + refresh: vi.fn<() => Promise>(), + pdfLoads: vi.fn(), +})); vi.mock("~/assets/assetUrls", () => ({ useAssetUrlRefresh: () => refresh })); vi.mock("~/hooks/useCopyToClipboard", () => ({ @@ -16,6 +19,19 @@ vi.mock("~/components/ChatMarkdown", () => ({ default: () => null })); vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: ({ children }: { children: ReactNode }) => children, })); +vi.mock("./PdfPreview", () => ({ + default: ({ src, onRetry }: { src: string; onRetry: () => void }) => { + useEffect(() => { + pdfLoads(src); + }, [src]); + return ( +
    + Open PDF + +
    + ); + }, +})); vi.mock("./ReadOnlySourcePreview", () => ({ default: ({ text }: { text: string }) =>
    {text}
    , })); @@ -29,7 +45,7 @@ vi.mock("./fileSurfaceChrome", () => ({ FileSurfaceNotice: ({ children }: { children: ReactNode }) =>
    {children}
    , })); -describe("attachment HTML preview recovery", () => { +describe("attachment HTML and PDF preview recovery", () => { const originalUrl = "https://environment.test/original.html"; const renewedUrl = "https://environment.test/renewed.html"; let now = 0; @@ -40,6 +56,7 @@ describe("attachment HTML preview recovery", () => { now = 0; vi.spyOn(Date, "now").mockImplementation(() => now); refresh.mockReset().mockResolvedValueOnce(originalUrl).mockResolvedValue(renewedUrl); + pdfLoads.mockClear(); vi.stubGlobal( "fetch", vi.fn(async () => new Response("

    Captured HTML

    ")), @@ -65,6 +82,28 @@ describe("attachment HTML preview recovery", () => { }); }; + const openRemotePdf = async () => { + await act(async () => { + renderer = create( + , + ); + }); + }; + + const retryPdf = async () => { + await act(async () => { + renderer.root + .findAllByType("button") + .find((button) => button.children[0] === "Retry PDF")! + .props.onClick(); + }); + }; + const toggleMode = async (label: string) => { await act(async () => { renderer.root.findByProps({ "aria-label": label }).props.onClick(); @@ -79,6 +118,9 @@ describe("attachment HTML preview recovery", () => { await toggleMode("Show rendered page"); expect(renderer.root.findByType("iframe").props.src).toBe(renewedUrl); + expect(renderer.root.findByType("iframe").props.sandbox).toBe( + "allow-scripts allow-forms allow-popups allow-modals", + ); await toggleMode("Show HTML source"); expect(refresh).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenCalledTimes(2); @@ -101,6 +143,29 @@ describe("attachment HTML preview recovery", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("reauthorizes a remote PDF before retrying its viewer", async () => { + await openRemotePdf(); + expect(renderer.root.findByType("a").props.href).toBe(originalUrl); + now = 61 * 60_000; + await retryPdf(); + expect(refresh).toHaveBeenCalledTimes(2); + expect(renderer.root.findByType("a").props.href).toBe(renewedUrl); + expect(pdfLoads.mock.calls.map(([src]) => src)).toEqual([originalUrl, renewedUrl]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not remount a PDF with an expired URL when reauthorization is unavailable", async () => { + await openRemotePdf(); + refresh.mockResolvedValue(null); + await retryPdf(); + expect(refresh).toHaveBeenCalledTimes(2); + expect(renderer.root.findAllByProps({ "aria-label": "PDF preview" })).toHaveLength(0); + expect(pdfLoads.mock.calls.map(([src]) => src)).toEqual([originalUrl]); + expect(renderer.root.findByProps({ role: "alert" }).children).toEqual([ + "Reconnect to the environment and try again.", + ]); + }); + it("can return to rendered HTML after local source decoding fails", async () => { const bytes = new Uint8Array([0x3c, 0x70, 0x3e, 0xe9]); const file = new Blob([bytes], { type: "text/html" }); diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 2d939d714..3fe542385 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -9,6 +9,7 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; +import { ZoomableImage } from "~/components/chat/ZoomableImage"; import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; @@ -242,7 +243,13 @@ export function AttachmentFilePreview(props: { ) ) : kind === "pdf" || kind === "html" ? ( - + setRevision((value) => value + 1)} + /> ) : kind === "audio" ? ( setError("Unable to load audio.")} /> ) : kind === "video" ? ( @@ -257,11 +264,12 @@ export function AttachmentFilePreview(props: { />
    ) : kind === "image" ? ( -
    - + setError("Unable to load image.")} />
    diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 0be9c724c..c6f521b36 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -1,39 +1,33 @@ -/** - * Chromium's viewer opens with its own toolbar, a thumbnail rail and a small - * zoom. The panel header is the only chrome we want, so ask for the page - * alone, fitted to the panel width. Pinch and keyboard zoom, scrolling, text - * selection and find still work inside the frame. - */ -const PDF_VIEWER_FRAGMENT = "#toolbar=0&view=FitH"; +import { lazy, Suspense } from "react"; + +const PdfPreview = lazy(() => import("./PdfPreview")); export const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); -/** - * Renders an HTML or PDF document from its URL. HTML runs in a sandboxed frame - * with an opaque origin, so a page cannot reach the app's session or storage. - * The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts. - */ +/** HTML uses an opaque sandbox origin so it cannot reach the app's session or storage. */ export function BrowserDocumentFrame(props: { readonly src: string; readonly title: string; readonly pdf: boolean; + readonly onRetry?: () => void | Promise; }) { - const className = "min-h-0 flex-1 border-0 bg-white"; return props.pdf ? ( - // oxlint-disable-next-line react/iframe-missing-sandbox -

    l9~NU%rT~9k=4|sA#*)!;;@qmn7NW zBX35(BwTrCwOwCg!?S1<(U{;vw^~rU5*+?UQFb_#1)49#2P4))Zw}TX6t3(emfnUZ zsFfOq#uKkp_qsI9Bs|{Iv)_DC-Xfv6!8oPr^J%ACbTU?Go6y@jRYLUntht*m|L7Eu z_D;{kQRPM7)NHv4?)!(fb9P3>I39_6HFkVWq%Q+}1v`DSrgo;~K~K%wN)z(WveK{K z6Q58yAV6<^GiIz1n8=4Cbly0N|7zZ}Pl(6HTexi)n&PVu#Y5~tveo3? z8`)L7!nC`G+ws*Yj$hH@J%9N$<_u~p(_TB-dp>eT>4+W0i09%``^Yzc!Dr~!qox0F>2??1h0zRbdWNkksp_uW0m|GYh^ohznosw_W z4}J zB!ZnWIs0cm-LLKI1__dH?C&*6Pc`GWKNB2SfBs=Hl!z>Unuu+Nu?aA>ap8WEz6C{E z;+l!-sSVItzy3M|OS47dt)E zjeL#LZ=4Qx=@%t!!EbfnO5vyPbYnw&Qat^l(=p}BuQ~E16&{L|+e=P{3ywl`J4?gP zXkaJYUfdU*J~zUhV-?rp$$G|EE|_(0qVLy$(WE|}RRv)Bk=!{y8QTMWl>eogM(3G- z(y7D2Hmcjwx%NH)Qn;+X_x@6dwmKUs@NcsF&=%p&ch45Mh|{+-D^jn*9F$$8(#~D7 z)jRd6`b{i1Iq>7c1jt=DU^|}?s_t%RMQfiVl~_MfHnUHytpY^6Z8-sT0y4f?yIItke} z$g29_RHjWp4rH$}QMLq7It{I&8Sj&aSl!WjUt&HmZM%dwgyC+v^NE}2v!zZ-cq_Jv z2~`ir*zv{JSU${bBHb65Ptj*o-A5TT1r+fruAHVoySY|MQe1yd2=zw{FvB~3<)?eC z2398DpbV%u-bFqu(v zANsY*Jf|uU#;FWXYkF4OOq}OXhLje+!)A1}Zectu5c+CTwk>FUbb+jHJCNYyIaq%A z4&2V}ncC@jfG9c`jUaJTzL=uSg6W|oN>7We6(+D(Uok41zNs%`fHEWIEIqzFs0UTy z?cFqA+SDct6M<#p{zn#XrH~>8OHNvhu&X0>&E0eGuB{Z{mI{v->?5Jqq!J-Q3&%X~ zm3M`rC)4DSWITYhj^QIO{)@dhA-GaO7LY+TWH7z~ey&^&hHU^QC(jUj2LNh|2dIm# z%GO~I_0zlzuz}b92G_4^(d_S*%B85n{GYQpJ&fuB?TaJ|!a6TWoS9FPNGl3EvxqZzNQ;i61*F<_#Dc=pl>xL7i z#&_{u#ug#EfkN7sYACaywCs;w(d2pb%U*t&%B-m!#@Rln0C;)%vqF3ewCq_(Ik=g% z#*$EfYv@8P53gb8mca+2rxkmJ|Gl-zLKl^%sD-;S;;rr`>0`k zZi)KY8#04G4-BkDVHmbW>8{whbJ9y*`(x64B~A+S_IWfj!gcF?BEQ{e74s89M4>2O zRK+*tERz&$1K*fuOorb5BEFLLZKwL+Pt9L_i0dy@1+-m51xHNje$I|MJ7H(>D@D+# zO@?{z<4~ka%6T@=rP5}EH!ZV}ZOtdjp_U_^5N1f=WOXpGI`!Pp#`954AR0frlg#E=dCcvrj7Xu&5?jgCl){JWR>yKHE{~3CYuexUs<*Npv##eDZp7O z#@=>~>bAs->~0I!W0Om5Dy2?VIMQ%tbeRhAvhLE>-LVr9H51fzQV+^MLiGf~TfnMZ z<){PI2up$zGbS!83R^lgFw=Xbl+l{syUa}0aeF4TTt(%nG;%CmhlRmJKvp@1HV)I8->CooURn41eBu^;#=v=-ln4I2C7jfQWJ{kN2f%B1a0E z!9&^#A)fy@`Y8Fjq7hjYrfy_J`616EYFu$i_a#{|^eLR`rZktgbuC5Bd#mUC7@geGzx)5sb>Jz^F_9H~QFm*-# z+P&{7g9))fcUciE?1Q`cow$Z3AopRFf-fjapR0{z94= z{+iVO+tkWqtq-gUYz2N^SG#5Y&F~X(;{>0=$LNWh=P;8!OL10qA6Yz$1VnlXDU$;) zloB>=m^+)9VQw_(GSMuh$4;(Iq;$Y)bjESAeqNZX%K+QU#bDZ`o60g}f7qCWS}pUZ z7j&2(JS`U3TYoy7-f6>PaVfD|caFRwoMPXbRgqTx&O$9b1z zu3tm;B?s(u)0BoH(5C7|?!(+|Z2-KU&ZWf4--Y@#tiV=aPy=}bgh!uWpS}nR!^SRb z(Db+JK?QS%Z~e?<)jY?^!&lX!0UDQvucmvVV)xCVFn%`&}^CVs5vJ>5^4xahSNt)<(6&d%+Qrc zIDU3-O%bH5Mw>~#9)t=w{6*b{r3_3!OwnLfy^z;_?J9l2pnHYPb5xKGD6=G|9z z-mo+Vc6+@AF7J9!*HtuT2p*&}c@`?Q9g7@MOTBt!9$4m8S1zUKl@yML=Ey2=JE+|jDoyON4N8$UWkEk5vB z65gkY!3k+M9_LF=mdC9DF>k~&-LxiKzhuY$XMMRdA{EkSxfph>!<%ty3GV|_4%3oV zSBy!=p5Ak?i?9`jH`q~ztSLN8tC)5mDWtEMaLjKdc5@(>)vnY-tZns{!l|}8&>nr& zo45%0oSwj1#bLv_@uZUoxSpkwQR*%O~T@~ZU>(<=}T0JM4ogz;K zx- zqD`Ol(`Z179^ViA)nd|8>&*J&wtz1@jqg;B4KKNrPAl2BUp9s9nc5WyJzPl^0aIJ; zT5x~Ao>!zfp2(-{_ro$KxUVmai#P)W^_|Ha?FZO6A?R88!%dJ^s-5{-@~$o`fjW3z z6XTT2ET2x_$RKVqRNXrMIAbim6$XQN)V^8byCpOdo=j_w8TvQYBmvuRWe!($P}^QV zcdnU#4E+tPCtTyz!)O?adwG4M{|SDE6ZqH~A)!oI*Yz`1ejncm{o4}Y)`w@UI#s=B~KLVH(#9XSmBL9&o$5Q zuzUhPo#)HIN$(z8z2ZMh26#!TT=?klN)C7P&Kx#W1d7SAb5mO?%xgLm1GC%a=c25{+i`m!-Jsf?yeicvdH?`5q+2G)oQ3Ey0kFLJx4q5U|<0T`(XaD+F{%Pg2W%iB&9Yaze@jl_)!K~aks}01Y8J*6GZc#(B~&* z)}dXnlDBn|!yTF~9c#khh0UOqfe#O}>06GKr$w6D_nD_d{|db_;0eLFb(PJZKtx@Uv$9*^Tp$286N13Na6+gVlCf2 zJ$t!&Y%7FS-}}4OQkHCeFi_N6>&dH}t~F_&@pd~ek4cAst9jBmUhu(I?%#&&8)7&T zIHB4(#Lew#z5O;sKbn>cZtvOq#xes5A`R}AL(l+9uP*RXx2KA-8@e6ScF7Ih@2Sdz ztAUdX0nL7x1D|R64%hw^ZSyLCbXhLsZ~r*wtXKB#;Ob*X^UdcP#y)Mi_01RbUt@W6 zKl!)dBlyi<)Rh8+@nSc4rY=j{E0hJucP^=69O=tZ?Im|B=HkG2!>R(Nok;lOtkF?j zi&Np_dGE73^9m-}TJQ060i#{Iwi01`Cbjmbox zp?1yX+me&?AejZ zd--RTq8SEa#iaVorMA}-*}Yzm3%xe%$L*2bG=hAlHDj4686p>U*G*=d0IL(3Mld{9 z9J=<-N{f%CV#f$!+f=UVq$;o#(pje1#*69YQ;dn?)+Rebs z@m9;pAJBf&8?6xe#vdHqZ&2>ujyKhQ&K%G|Ww*KqAL|A;+N6Q7bfU@cRwjnwGR|=O zNwE5Hm*=8v6)gVa-jOS#M9Z%hduzReT_w(*v~uFwON9Tyabh;n<9)KB_fh=M#zL0I zBfrA-YTTpQ`cC@*Bb!wLN0Eas<#U~&$}MDUDDwK(>SusQE=~Q042MtV@N3A{87=Um z{Tx<<(FfT`Xp~E^?NhPz;TWN&xZ=*I(-7e|&lTs&fie48yZdKlRvmnW$JHQ|bh@;S z6E$APACv|s-kTWHT0%$AydLhpTXnZL*voCc*Ess@qRxHnl5`9ocp<|0!C9#NK)4r9 zSk(rX+cUO+H=>13@z`7C-a6X9g%moyCtBlSE9bPZ735Si(RckzMq0N_!9dZY&Vk*= z@U=*I^I!L*F5|ff?Ma5&R@fdh_%zZVCz-k)mRtdD#6 zDci62bu$24~#T1IitPeavDmS}DBNSkyBev)_YV~H1TTt3NluH~5wL9-j+xvv71 z;qsb>^Y8qhq>jxJ>704mZnM-M*B}Jm@tTXW5>*CU0^(JU*^JQ!BPfm#ti-k$$Zv2S zvU!*|!}Z$bvC}@{60L<>p;OXxiM8oQnLE$bBO-raFS(P~f-#)Fq47Qg%=&xuTO6Gy z!4!-i1k-n(Mm=<@G6Pqy$=-i%_iXzb%INi>c7;)F26wL$Uypv#fIfH*0N?Xpug|J- zPERrlJBXuCC(6eaKC3gU-I3JXIcqZf#lE@y`QzTj!j}ji=oKH5VjKJWLAj_%|7q~h zN^;yy^z8N@i?5XwBxXNSQ+|2X!9bJsN6 zKl?f&N|_FN5%((+)bI~JyjoGdd5tRo&A>5W~)BujoW*+6sx z;cXU+tHqZl+Yf87Rf(^zD3|e97ZuLszM&BbdB8{XUbT4VHY&G zL30?hSXU?ut@w}X=1mL7DQRBGIj%-}>AvL2>b09*c#|7B1V7T=A54A@8pSt2jpqns zFMp`U2*zXhZyu4dNuQH%UTnU}pyPlY=K~BLH58ra=|k~iNPGz3u<3&G3NUXzGlEDSr7=@l zoe@zEUA~9`uz*`v5{8pM;nUgzSA1U8yp~O^Vq^WJhycFp@p*jQ5`u0lGnwqSY_v@) zKkyQ{{wlnlbB;2>z9q0jUxfBgiMj-GQb)Mf2clsN6Ri(?dxQTMpx~Vhiz8RUtD&R3 zkyoQiRax@D%O4(;GtLOZc2aNOoSb>Py!Wbd1ktp7hIohmE8sZaHD7K|^EyVit!k() zxHIwjrXNbOQ{pR{HBW_WvjU0VZ<_!s4+pG^GE1MLJO}2yy!4$)eX3hl__$3iZ9ywJ z>s1R|5tg5mNnkAfOB2kQqL)_Q5^2xO6H}S+=z`dOJqz^-c-TJ2(c`E1rgW*g z;(?RC^p5(TbkVse;vnMm@u~cj_haMMQM;q@NM&T8KrB8u=jI}>w;ZXAy4pi$WB@7f zON<=$g7rIF#W2O=vd#>Elj4lf2W^dj|Lbw}nM~tI&QRLBm$KH2OT9pUwo_ZmLo$@2 zjLM`#Fu(t#w#@Fg{I(m+TC!NfO~Bd2N=D0fQYf!_BYTfMM;{i5n%$d^suDT-l|^fB zS<#0KC~2pN+_{Fo56|vKcd~^!+C84P8wmqot|>qQdB=|Wrak{tx$D9EV947F{V_j4 zmFvh?(NHOI#}}h_xRRX}B&MXaoe4d%CfUT zN(+pS({nQL?~N4od6<<_Q~B0~KVBEMV#efVIu+bdhZyaRg%Ubeto}D_{prx<|slVbc4PDA|)2T0{{XSQUkIH`@)WQ2Bgn1V}PL5 z<%BHaV^{E2O3i2SEkpb7NbKaaN$99zVw!0FztuiF>+A5_Y~Bf;2RfnSw4Q{ADt~y& z&#A7Ignl{M#IexB@2`vXvl)9ywidBh{8WW!;P#9_7YTN_?sTxnn5OVE>vmbBXP+@S z|6|lmQ7#>fQP!u|VVJrf`3vy3!?9R%QmiasXg#LZq{*bH^yJO*_T?nmgaXktcpOWw{m(g#OE|b;rJM27q z&LSiB&e?zcxEvBQIdmfYDZ|+mkHvBSt9mjw zKA{PuZHCPoj{oXAQIyqlc5(*IenDV7-i^(=2YNkbE3 zVFZ0b%Cy%kA*5lfA*{YTV5+A-qqk=&`3FF|^~0T^kQzxn7~Ld5&)hCVJ;t|seYfUe z4MCzLiAtW-R#;mmVV4TW0ls~Pd#JTV7H2Al?VO^Yu=8# zz0UwbCj>*a3`5SbMYkk*4-H=wn`}HF&mvd24z|LVw{f*b zR(Re-)-m4TFIjqN6g_-LV$ISMJNQ>=5pcDd%6>XlkV zfcWu?clWj}Dy@?3LP*7DOBZ`!l+Py!;%Kj(c00(jjrlUf>VI~BCn%u^*!j*#F}xrW z<+OpGkKu?IieqqXGsiIK^#Dkg!ZA~|Z)JLo|GC~g6ud2-rG0<88er7tX`#H)DyxDwg`XLG zy^+3chs3?)6ulera^Oz*iYOP&QvPmSU1u4r?|6t2Sb5zDK35HAdIaKLtss=mB^C4b zae!obG;Z+b=f~7FU9d9Z)?0R1e^nO21qJZwh~^5@{8jwMOQPrp+;8E`#v|$G%nKUC zzgspJzhh!-GH##!8P7giv=+%Hc3K;%M)DRFLX>DPkFbUOJJ zAFAKYa_qg+Yo;sG&s)N=t1kLPbj7ZF9i$HE69X`=M|n(}78t&-AQXRPQ4J=)SG$=- zf0(8Ke!Ts1lq9jO>Q%t@PXwzXf`pOtqo=k`8GxbMQn#sAw>nSn^o$H2UbR`j_S9%B z_Rvn!$3`tGgRl_>sQS?u!(fl0IbQ2DBZY2W_M~s-GHk!Ev$rbt0b|h`8O8jeHe};$ zG<~srW^rr?pZO`4OHC4!;4q1-X^2_kiag@bylf*!{sVK!r)XSd)PwrKf1tF7k`8j& zo~OB;fn-+PCB8-N$6-YYDe5;}IKW~OETEqlqL0FbPS1Z3S9c47QRzLZH z_V>|*{h}rW{$l6RXz^y@XDsc!n2+^pt8hZ;4!LEgD;7$^fR2z}>WJ;x&&&&-o2~hN z9$o<|$1y=D-OK(q;=%4TEMM}(^~Cl~d}F~UDe59KQ6$?!wBPI}l(8Fl8DDm|@wR_; z=X;lP#=6(u;h6^PyIgASHL2fJ>;`bZ9f4}al7RF(?#&r(+m(r?u~y!yG5E*&qXj}J z+n5=TO$O(NB}eabhtQx5H|@4X@24Bn*8Xok-j51=GpoJ1II}FRzZZm-kUTA8WXwsH zF6Q)iS4@h26)luY2H+@jUE+T|DEj-tcWRDCK-Duy6m*FYaJTp8Wxa+Ku0z}aDkPr3 ze}S~$iS{oqWRqHTri zSt#!Wc;vUwsWYR}DC6%wT+P@S#Dg%f|I(Dv^{BqZSYr+IRBgRU8r?6ENIsByIx8osv)pyM8*a&i+eZ=zWa4$?$v%jutMqEk$m67s4=1P&ml9oC0dkIphWs@<|E|Pl@}XXJQNgj+6f$6)}s&qy#Q3 zSn5gV&(0M5x^3ofR)AY&rP9F#C;umE-;JJ4tzNQ_RXi$C^h_#6b$=C&n~{6L33{;S zRyAdQ;hg@UWm#tp@N-XN+Vs&17Vm|;JB@V1Dt~dUPSapDadu_!EKR@MR`+m?P`>hwo{?f+4=>ED%pr_%aVmyy<$A*d^Glaak-iRYx5 zsxqdhmz)n%10Qth2i_Xb2}yozFJMM25X)#~rFOl9dbdK~Oqs?I?tin0RU7|B_mBCnxCv-`Pm? zuz%;P#7OXGfzt(3jy84+*kJ~Ehy$PAeq0i#2|y@K^Bb=m{!`mZ{5k-a{cx zSCvaRFE~)$8jT=28T}L9g8ooh4Iddwv=WHx(WU!eLkm8{+aqcdulXN+hrd19FuZ(;)uAGvVS0)hA za3%5CGy6+*DpD_`iVbqlb!K5sF|khvX{vf_(_&dloqVX9k4pa`mdIh>s#r88e?(UZ zQ}}FirC~z+S$>s#EmoJziLuBc&ibH6O+zINYO*UG-DY{Z0+59`j(kiLk!>uSt*C!R zM#dEDP1)2(h8X^flf3-h@XHTIJ^K`b8ZX<}#1QL3&-R_>@?sv74=G2Kxn0@k-dTIv z7mU>IvqjvneoaSpyLk@EjZ9WI7ch|T&7u9q8}erN0Y25s=%st~N&b`iQ_(@HN`-k# zg2Nm&{NM{P$OT1JJ-upyh6mb-R5)!XuV@spck1`Ze5dNGrM+Q|mT9#xHYzvxuE_a+ z87+?t|IDxtT&*(|wlp@e5-@$HUznsUx5M`pnfrzP_UuWa9a?WR(juL_LMEchp}1ri zUKm=JpptJ-OH$+MH#?`j4|;)M+WViYwK!0Dz;55p;CXk$4E;*l-(-{7R)LYdv84D? z@aDS-B!^tGKyXuheBF$X?L`&Un4H2b_IqdIubxe%gyw8*I-9BI8)r-%SuE9mS&yWx zZm}(3S5f`A8=C$u#T7U2!Ou761DQ(E&(=WyViTL+m-oPWihY3ZS_epb1=gM3%p_da zlOS#EO?X4!Qr`z*zGqZPz*|pFB}<3}W52Yhj~*h1>ahZo(gHXCJ5iZx(faDpwNDBU>BvgHG&ZzC(VJ`A{m+2(4 zF^+;11@L`lZ(Xb6ous9C@CMpO|BQF$q0tI(y3cz}^DbdSQ{=!U8F2dh)mtZ__r0qq zQrv5oFK2_bnV65gu4tvm&Aa$x!eCnTl*+v5*38AmT>%CeD6Lx)N5B`q6nWh9@kzC>+^3aM(F{exa)%JqIyiQ$^#+@TmYQNA2Z=0FD? z36r0(z{jhoVFgSoWF!?Sd+tmC%|zHiL62RV{Z2P8c6tfI=KYNzulS^%9%FtE*;?4` z&Wgo2EJt76FU7{2Be6-Xc6X}Uss;M@bxW=)W(d^2InK~tI>kGfTWuPyt>i?~b1EdX zv6oblXNIGeFX<9D5KA3l39D(rWa;}eqw^k>VyOEc)1JOX6WR-9r1slbbcnBED^Bu* z>%ffM!yH)kFtZkMNa$xmI=Arl%wVn6iuNz!SheQu#p%Dd4E!gqB6*FUj;a}f4yLZ_ zc~)1cpBucKTnLDhXuVP~T8}ZaR|rwTo--pF!@4(#yB3GryZXxvtp%s=nC#GGT+5}0 zs}nN5a?1wMiumw}vDQ>%fWX=W!|$i1eNAzU`v5(UaKraEDziiN`+DlW5=Z)&^Ec)X z%-O{WiOg^O3YzePeH6jU@P7oWe(j=*GAKkeiO^MtZ06sd5;c90kl^hZ)dU`H#wwTGeL*;^VWoE_Tj?5FmY?VbS_w?Uq z=n&56*_M~A@2zBWL$XaD+Uyh(Obl6GDLhGzW`2HHa=YXx6hN`8e0oF7ypJJna$xRs z)ng&s{f3v?%SGO$o>*|VbsFEkHd*x3BFyyxZqO&lA%bdv!>gYx?3cY}WFVUQ=@!?u%nS#LBLemY{?x{4DwA zgCQw*({>7Eg*z3?LdcVhdX>lWWOy?avvWZ??a%K(98K;7fmFc~HTk1e)RX_kHJG8E z7N)SKgECO@P;t|$c4Jx1>C_4rV{$H9XC#^PPWNqw95aP4@=)aA<{_lthm-DLdH9}f z#%;1-V7dE8QtxvycZpGc!C2vLcZkB8%ZVmwKa%)CkESVbZ8y_4u+j5es*)qEw4VVi z>U$%=L~SMO;cJFA=l3>&2FrHq;%8Avls!rHTh@{$9aR~8F#h#`{OU5N*#jB{k3hK? z#Frg-f&B45{qxSEIBVMJc@CkTk9*k9lXia!r%u>ozbnR8#^SykSv#w)Bk%ZPU;Byu zCgwKwUd?HcvUvw{jTB*G54JqB1o%XqWSn_?bmQ#4eI}D=iEmLSy`b_9KM;x;4J3Q51Pp zyL7%Su(E5qF=G6!?2C(Xs>-Mnq22L}@w)KJx4jzgN+%g@+4ttsk6}U7Kb7-rAuuee z0)HlBq_{C`|H9K~*+a>7_4U-;BjK4)Ktx3-=#=mCsK?WMsQDeeBu~FIz>y4U@e8`L zjqH)^D+EjvHB&A}osufaQtYn2GDt8}X-BDfbY(uG5Hk)qKRO>#CT}^lZYIDsJmD8d z;7}rE_{JyQQg#`3p~@&Hb-BvkMBtN=jGnfCAE%M-cQZ1aw@trr*GxOxTmW1Vm%$JNf zfQMigoO9XR74CRB2XZyEoUoCqbY+UVhL>>P@T5nR@yDRL$Ca!{d@)Hkq=IVPEwip% zcE2A$G>@i-14u%S5pWGFKfVH1uHNO;Sn$TKV&YkmlM8U}?-h}jXQHF+0IWWTueJ_G~!@tnbs87F~vVJT0AZ4_rHqg)x8+m9Lh3Jz}!tebZ|r_xo5L{8wV!mrb;0{zA-R8dNQ%hlQ}O`uRfhdY-$q zj7@NrX7vOvS+MDj;iLV}jI3Iy&!~T{6hHqf=F}h#Z+(UkJbc}Jbo8%l;>jOv;(b|z z_CLCDo|fZ3a%LIc-Qi)W-9G_9rWa37V=idIB90$}w`-oil?Xf>(vs+~shzu=lww-8 zwClK|A1_f*xF0Y&QRw%5qKNe8>(s=Xjh4OUI7b^w5t#_Q!P=d0cx@hKUmouDxW&kH zje7Fy@dis+a<=Syo5r|z)nz7x`&2g70wK42S_Z6X2*=fB;VTz10hEmG3Bw~w##U`S zpXz;vJdOjtD}o)v_+=UF4Hrq_+3uO1pZXXutf%{yZU7%l*f#RIgR3B{xj_ zzfNI?`!AQhk7{#1EQB(y&ZWPTru?gkEwK&BL&kvmE0O>s9|Q}m$j>% zMkuBk9OC!4coJp`H-iuVKZM#TK|!bsrIL*3{z3$$Dbjs|GGdfz{$mCPcc-}QN7+9> z>xh4hZ}^o*z?Zs0wALFsr91@#XoXW5a|X-uwfUpUSq_4%b}sKq@n*QT?yEH>uq?*` zqgd8R{n>Q=Qt?B_Zj*lrL-a)r%Y|&MdvkUE|1K&%PJ zGJ@uyy7yf|$zOIb-=YlTE)lIu9Ett%5P-`wqEct;0;0-wG;41F&FF711^Xs{`#K8# zRpMCUyA*x4X2$qka|aXgv;`&@y*QKlz3cgfUt@O}{InFL{tbSbInD){U;0VE6(!j! z{$?}-StDDpeU!&y+D751D`)8wlw9oZ^dfk0P?#@+4t_}Z`VnHv^6(NR$X-7vTs2Mk zo-=IS!zsnNQ;e#>9UJqC8g_6z^bveA#wDN&^wZ*(X8BhDMfT};)g1U zCDTSwe%rxi;?)faN*377U~kXs5rgVH;_6^x>U(kJjwNr)rhl$md_p@F;JiXe&K}omprjFX3`VUS5!!uH8FE&TWhSlV~mNVeR zTT~zr&V$?WS;ix&a64xwh4o5@#UL=p%&tDvY|#^xKlI0HdT8n0L5KM$rHBQzNNo`J z!jubK3~9*8JAeD?<#4g;1$AD36k$CNzPg51U;#p(m!97f)$5G0&f0kK?XI$3iBuZ+ zBR~oYUjdX*W~0e@T~y$+=kUkW=1h=IxoqKI30J`9(idR1e9$jeU&pAQR8PY*uEv=? zp-ak8!}tk*mn1X;DpMc&L19jNx_rd?J9U^_?yJGJQ|bHfW`(n{@~5+dK#Wy-!7_R$rQos~MDt zo%&y2ih5(64~5(w4Y$UAVa$OnFOaLqdmPUd9E7E&X_&iL7%VzQ`e+}T+#Yak454D3 zMk9;JWy1Hc6FE_ZFbvmA$)4TWyc_lGZnz(A=dvONAVTUk-B6){tYS8jk48UZ6Q_GYhHT&4P z^|bU8+0S%sI`}&`%+RZG&XkmFY8hc>Am_w`TLt)RVXF^|S8RocyCV6`0^VeCzUV%u zluR$SBxI?k@pK1SJE05ir}rDbZSBc}eXdb$y0#YeQS~9QO3DTXy?ieHI*%e{e!SOk|$cfWP z$#0NCBv&2i6>bnLb1_buH@3n5V_2;W_gg;VJCc7EDA)I2O?Lf%YO)`=`l{kC>4Rf} z9KW~Ww>j=lJSib*ZIA52FS>=1#99=HG?|WCi>;u9hp3igD;o42d?5n)%wD8!(hEL^A#A_Xw&e)%TQ~}yt!M}@y$ZJJIh+;#xIu- zJ9j_TeC`C#{PEJ4hkuY}8fB$f7PDmD?C1=A$zQeC^77;;_zrKhE-dZ3*N!Nc`T9SW z>J8zs$2#57BYLFeS}&nHLs2k8IcjDmJT!PvY_(|X{)qdd`j z7&FUO|ChTH91BLn?gE3LjVF%KP`!-9aN)7dy#9#YA*e_8-pUyw_vCwj?d%1~`~$qj zX)P5&0xV_;|1fpmnH~)6+$A<7sdV&uhp7BT^~D$5YS>FqXekhB;MSDIJ5lbc1TUqI zxWfhBXGEmAWPa)lWoeyk)Hi)lv#JCi)#hWjpVxX&f=?)SsMIa8#POF7#I#pId$j9~Mg zwaYuPxm+)xN4wjzN4_#*i~+~O2t~d^eMn)9>(QEL4nH_|%1E(xt@tFF5K%<7DeK6= z)xVbCl`cq;U7K*NN9ga3^~jfBTRm&W4bXzTL(Hn}*zO{Z_Hi?1;`=vp%#&;DRYKYw zhHAb`l)LC;8I)-5X>jC=T&6?_d542XT5I*OcO6f$D`z{lgeCGxp}w1ADnjFh`PnSq z=}V4fe@|0QWpnaq8*1IBpD;5XH&pP!O6hOLf54p}$uCjW*H}d@DaIS0o$?@R4DnIoo ze7tq$D52|=b_g|iZqGExc9f2QPp}AcfGm(Xfw%52U7s+J^ehl-$y7Bi*qc6uZK3;G zk78j%K12v{lyO5blLwcx*WR24`|%=k4#AX5jXdAxBh~F>UYJqtd>_CkhKyq&_uR`z zH-0L!|C0RI8rR@S?n(tjBxp}LU`VN%MYYg9C`kNWJ3N^ zTy(aiBPMgVP?t3)=pQ?bm1f>bRO&$oE>!(GT?bOjjP5UIw3LuQDyP-2a5|}H){nwm zUftlor~U&+8=pn^ERe(`LnHY6?w41OZaOp6WZ+v-mQMRM2;i9~GVS(*{ozmWlm=9K z%k&&*ssSE==9m+?!MnMuak&;O`OKz!n0aCF#!oxiwWzlH7G*E3PifBB(^rOG@7FzC zwTBJZ>sNkb&hfq3^#1_2KuEvKcl38GiN0h_j>CfBV}Q8Ok>Er9v>x&nA0eV3^&cB> zF%UQ7a7n)JAEhG>(Bz{paFS*>0F2*pz1;FAb-(C?fxnYz4)=(M z`ED6@uQ6lid#ca!q(2&B|FONOzeQty3k51iWa)xj1W+~XgSZ{P*^#~R;lps=K0CGf z&;ewn&{|5I^BFlWjter=BbIAb0lW{&244>APXWuG`6CWjVK>gR=;3E7Ju{RYRpZRE zdiw*&I_Xn>Mz^1tr~vt{PjlcKA1I9=$KUKL`SQWoN3V~BbY_36+OSvUEn@Pm!vRzJ zgPoW?`+5R6T-{XRF(N51R~kY}xwJ)~>H3_fDxVmoB< zZxk*8VitVE(Qiz~lP$ppumRQLympbE`B$>;m6Z{RTYIA{C(>XXvTru~JkxpL9DT-Y zftr!+by8mkj(*SKbG_D-eG3_ zA9#-RA3%8wP@s6f>c#0-|_-DGzbPJ44xj zBL4gSfAZ<4-~D}#Jo6~;y4qj5@f_d3NOg!uifIU7wFy#!seRQW? zLDL673?ryH9zJ^kx8GkxG`;gPQSrIAxFswK zDmDtuXzIXue1Gsh4|uox-gg*Pv-2GlSm4a(riiJV0O~2~bakAKwH^ETN0>wslP8dU znxZNT|QyvGDp} zW}S9NAoZ&L!~Cs}KmPa^%pd-n+_U_+DB#=m2}Ljin28S5EI3Zsm%EgRo`NR*Mi z_e|)4k9dfInzR6W<Pk&x51y^+Qj%8v>G$4za7v5nHoQA< z)4%$r=*B94)&BtBeRD>l_#0~{^_jXQ`NADQ*9r{nF*DWS2H4V`wTJg17R=ipZ#eXMs>L$Y)5^xA_uV=Lh_O&j`V{KAq3AaWg)%;P2UDR)qZWaJIh_mg;Py4DL z_Ynf2R@H)LZEN&u#&s#ZIDPG#V#6uH5~c!YPVUh_XBnhfzcu*RC{4oTwdaM>s@uKx zjI``O^PinFGN3-lrQf>m`DI%<1B6nmC1fHy?XgoJT7?B7DS0L&-Clj*>gMXt#ediT zn+cf9DDZJsVEz*TA7{ZQ-BRGkfAF7t^JA~NedE>b^>^Zds7L9VyNwT3KX~N`AW3w= zw3vF}DwCt?VO};+!H-eTYsaM#(J&I@7X&Se+4+S;Tx@E{fYLo&QQ^ANpqdL+7BB4S z^b1=voXLk+cqtN=*py|nK_m0a@7VM}PehCE%HFg9IRlhGas8u&+L)g+ZOC5*DA&?I z>qq)hKWvT@Hr5|}*kI=5G9oKt}{bP@-rsL-ogwcn9HL7okxB z26n2zbvUYg#^f$jvHoPTcdWB%#a(;b5lKy@#%ZwMGtIeGG>5)W9YbG?);3%T-HTp$b+*ppZ>=8u@p}gZ?U*Zq0Lt@&?dY}W0Q{h5P`U6 zQ+?tmz2cxI*-)s$ISuUo2jLtSHaIwA-3GWG_Iqx{24q=@KWw|L>d!k z!2=Y}TaNYM?Jprhh)F-N z0NXrY@B?dU3zIfuOMYXU-#!TtjPS+ZjA?!u2o{DuC(Icy=K3gl$d`fZyw3Os%oe+R z%2%l}AnQ*I?jQA-3)?YQ0ckw$8}_06hMi)QsQ(=h6H5E#QV2Qbx}Qm{bc%@0mtz{>`VDWCa(gxYhXM z*x=keoWnfKP?({tO@`@wyNX`{H@?no=j;3kt8$q>Ov-Mbw22)`qLb&%&9d1CSPkMU zTKx{n-xlh%8De>~9rr+INf+477n@bZz6P%Qu>XY&*U`C$_N&&O(3tvdIZ({Y7d1wa z#DL88%2Yny2Z@C{0>~F@HYtObRfi1#_hG?VNanx5D0bD~_($7u4``)`1%5|;7@z6c zKN@1W1q@#N1uUB_dn__0_#9`LJQe3M6!Utf$a>s&%)`EvKO*}1Q#GZ{{OXk{^Z8j| zc_9;8(TGJEQoj<&zCeja4R3q>Q^q9I1-8$SN*ZEcFtmc z(sF9mwE#OdG0pE7ei?J${kaAc`S~)pKW3gEJ?q8YbFQ@_@bt{fnW51!!8NhnG$|2* zQ2}@&WdSXq?U;RY<&oqa_z_NV-P4GS`qt>NfX`tr*rU_dLo3K0{=0*)^!`_3* zym281Y+;t)jDD+vr4xZS>rWtdwqfOv`@HJwX8}CMnN1#Ddw*xxGh64^`M`KQql3*n z*Ux>nPh0Y__Hh0dnl7u>?Z%pmxojrbnfnVqRNe~+Mj9{$ug)*`49T^E`u;=gKFq5* zoLL58wa#>K+UA+Smbcsc+Ii1x_Fc>kT+f(hGroK_#N66XwNxZE$bNDn+Rw#Ze`%|* zi*G$wI{CP7^{rUW6i)j+Gkd<<5TNYAVF2_ZK58s~Ecu7%FtGo?);SWccOpXeXCUuC zr|E>ul@l8`mVpr{M%l1A@>wSuz(5hX4jgyf+P^UD1Bn}_pDEcHuFi>QHi|%zDRs!1 zw$wy%2`k(ZQueiX9bq})>rC96@@tno3Yc}}I(Lyf25ndGyuJO%Uw-D9fA%xZKJzH> ziB!P%=qIv<$vp~e3cUW!Z~xNx&q#ek7+>Gs4eNQXCu+F$2*&Jp5(C-1MGaKm?RXX+ zAN-x)>0Xn%@tr+Cd=r?jgG1)rI#~1i1=dXWE;GGeC`M(%9{T>ZJZ`^w4l_I$+j}hb zjwQc$3%Bs;>jHS~+0j@)3yx(JbNtW=^HxB9PO!$!2N?J|*zI0$kE|b?7=^<+{wZJZ zxzARB-A3|P0hjy@f7tkg%=x=}t3Mnk60t`(ROONeaY~zub*oXGGP?V|I6nmQIdR=D z(vJ}L+tDN32(qY6g3Z2!L3kCob)E z#!eC_)qrrxZ#ub7(bv7Ww7le(W-JByrA^n#7}{89fFnO!OU=v2vgAP>3|KV=8T`V) zZ-X+@m672+@+-Z9gg-3MB7|`yaIjM>XUJzHW4eT}S$_+Qj5v?BPs-(#c`xv3-v}rs z3at8Boa(-)Kl2|UXMER>$SgTX;2qa22?bRFz*(o%W(g=93jnE&#$Q%uWKQIHXjs-A zcga61g{jlwG{zl$+@BU=#&S}d`w0seE8k_zud-8c`TYDrpRTb7IP!_hIOU6d>BO87 zt87_l7)V2&%@-RwGOO2~eFK1mfpA_%(t55m`w;_L;jqcE?nTvKDO@9P*_ioGgV{Hb zO0@@$HcFT*I$XlX0zN10MQ?Oq@b}`|z11K5`!Jo~tE}@wDVbljDHgEsXyp0D=HmPa z*dw#(vSYy}R?h)PPDW+Yoqv6I_x||bzW$o0pMKZ(Fw9&=ftQE^kG(|dm@-CzQs8I) z$5TK4slWbn|Iy>mz3NqAd~3X1+P|wJp0Ioo#UmHbS-+Ip5+<4R6#!ffe_)E=KQ(gF_Ku`s9KBg85V19zX3s(ypA02Fc_ zeq*Yi#&S;ZQiYrsf7!!j0qBlP6PkHoioeLogq5T36Gvazt7qF8Bf=n32l{PZL zmwabdP5Zg};}jv7Q-Qa&c z906G4IqOhRf=0`ohIFh5td~}hU{@s0j*~2V{EN^&ugrDeGaAUs2c%YUjla7-Sode5 zv{7RI;T}u(69Hl-(5K~!UO1zgs~ zNhIO9ZhWVYBEX?~jgQEgRCC7K?A&Di#%F$RFY=S1dbEt|djEi0^@qoyXfM3VsrG@( ze*nQ+U1F!b1DTU$(ciJecP9~uabiqgB?|~pu9>m&lIrGpH%t~p4*7vZkgzL`f>!>H z2dwRlH(wZ|8H?1p=4?z5Y$um{tzfqg@LD%Jkg#v*;g_R)wNHCE zisQI&Ax`OY$O8_B@LJQLY+CY~I7V7H#|7VO!27!Ig>o!;mj5y?HHeGiCo*eB7X9Nm zvA1Z22_FRrAH!o#oNJ~FPTHS~>Fb~T)DQjP_q^v@{tF_{7zJKR3gAwiF$%n76?olS z-u7GLkN$OL4W78wK+nbTr-&EM{t_9q~09(0Akqf`C!vo z2;A391bXnCzki(B#su7C;9Q?#kM)z1365**`{?yIJ3ch(|J%~m4Z>ybt%ounHuR!D zC`o5oe5Ug4w#(gO!iV9QTh_3j9T4!aAFw6%rGTq1tiW#kSXO_~2EKz08CTA=K5B|M zxDgmV*StCI;q#_}uVYVc3f7$cX$?;NbHKU}g^XV4kDgUlmBLVdg{R3D%*@i^S zg{U$5I{7dn)~>FB2fm`@6-r?=Y>R2l?d?d?c=5B+GmKulriJb+WlwB~!t(>W);!5h zxm+7UQBi3OuhVyL{VE{aSAJ00HxDhAK{h|5Qz6=tuk+pcx)*|bBxU#tihW>QGJ^9g zoj?7R)O^gx!J>{v)u6uiwUMTIhTUt-RbO|+dXrBuAhuFz_(fh^3&QmE7B-40$@-cc z|2`B!zm-G%o#S=Rd)8J#_&LI~pPy=3LNreApz#ZPV&n6H2~bHsBU(t^f5_RMAFhcl zZyOaG_KQ>*2zG?8;RG=BbpgC+McG7(>x$}GiZdG|9(&8G&epJD_*sV;!&uKp8$ra1 zug;ZIKGmn^PehHdUN0rODqbr;F=sb`Y~OQxd;1rkdFI{EDA!y^ftQ{F{$;%MDw~Q% zfu_Lg-}1IEk0<`u$5Z=0q7A%pzA#y$~-x$WqU`6)_+HOy` zQ8BBr{?P`f_oYiR`edJaQJ$)^YJ=Bzuh@kN0d!x&E}Rg3pY}yk$uKtoU5ohGDQnqJ z`1Iug6pm3DJZn>plqt;Spgru?9}=;TB+MiJDm1zbyC}&aJuGx29$W;7;IP287Vu%v zlgs3Xt>ixNH~WF#&)8w>IpH)p_j4*t#H+RO5@0J+#kv{nF4wD0p}nuLXBUFqB4fZJMKsQ#)wIqt1^t+Pcq;-tON_pS+F%+k4CyE^!M0hk;nf zDBNL9CoempKM&}>FF@Llc<(Ff37@{O>sAnQ!HTK$g^ONJ*NLwH<~KVc+1%G*bG;VI zd(PXz1`#=8`1x_Urlsyp++!Y2j~R{Y{?nR`6)+HPH0Ek6{I&K?22NjdtJvtQ3I=W) z%h!7W#Cz+o(Q;!Sd&xYiO_o(@)E^F=^J)vVv46r>Evgww+-b~SA`gSX?=>m`1R#vH zkN>dh`>(DZ{pF{gdiQt8H`h_%rK>>uk5<2Qi=4_vf%m`X-QV#kAO5*F#{W{l*M=G& z)T0;w-xq1)mtH-R!(pF4IQ3FU0%_udV?Lhg*n^{oyw)qO{KAE`@~aM9$+v^CmakS0 zn1;i)u`30Uy?P|_9=-K}-tA-m>4#m*d`Tt={X2woTG8}!)_%nj-ze%=0mAC!VOyNF zX13-tpCQuD^D`zw!na}($vx`r^`j=>+$+{Z$EDU8k07i|K*JM*NEYbK0K z+O#Eb@mZlf#SdHOYrP8P-ua6!j=%4+^jA1;s#G{`W>_0K^ zKv|8I1AFtK5o2SDVZO2ihkwzND`u;8l47rH@|vxj_BkgwSPSc%jFkD;S{&`UR>24W zqJf&@+Rq>U;s>JD&ZL9y-q#b0-1p3wPl6`V-#uUu0y~Urdp8pki2^3A6a9S#<+XJL?p-_l&g%+Cc;`mVVr3pYoBD zNwv;gVXORicOSgIz53c`pZ&la=MVqTb;ippS0n4QY`D25Y}W>C(Ib#lps{MhLH418%vi}*m1C|(=4N?fasweYY3QG#VY zSPEXVx%ueF9t$40y5pJd zH$apbzRMdxcJt3LnNRm$WHJUR;~&}^-)9A{(q+Gh6Myk+=uiOj;p1QmVQF%{l=;YK zJtitbT%|*8S*Ujh-*$dG#x&3Jx{vHXa~Y#K>kopnt$FznPxmTVYi#GY%vxCw+a1_@ z%m<8XBc%Cg2OlF#kS&aHM)vpHrqM~m9s?LRyJ1{k&YKU!V>~~P&GiF@ z;k{?t;&LbzX4xVS>-L@=UCe2wiqmbsn6-a5#~8hR`?}3*^SO_Lc~(i_`Go^MkBc)Z z!Bd9~oUEhGdkTaN7CG66xGW&-hV(h{^-M{ho}c+UOuo;uW^iNU-xB9AshrGfKe6Q$ zhqi^QI`6gj!n4wXXjLdDj6 zyg9fBuJwO+cN^PxKYH}oUwPmAzVWBwoiPf$Tok~aJYy7iSt{^`x4iw$@sR&J@oxKK zzgztf_sh|HHK+%?ef1cRg`e$u$;2xrowbhu^+GZ6M71NvuM|MbcfvcGPE%yCqtAQJ zg~>l$);@a#D2D5*IToc0F^byXfqi`$AcBg*`2KJq4i@`j=CTs6yoCWgilO$v^+CQr zcHGmx2ONAHs_D!LyPP6ojp4>)>9V?wn1_6gZ|voVkUiNO#LTfiXp|r3`}nLsy3oDk zdXIZXh>Q9cCw7q%rN)&$tTeojnIIq(a|sg26sLT}mj0z#gego3df-PM$`)o~xz9MT zvCybLWwOMwBUUzZnEmmajRp)K&l)U60^G}YLg^1)7QiTskZo!Wzt5_A4%U5qVDPu< z%5#gFwms6aVMKTRiA}!hL}#lG2Il_${J@B{c&qLe&nv}eK`5GanJ=ERqRu?Os2vy# z^o3r?!C9a$*yA5#Xh;inNDOhE)7BROGLLmZ2;}fS3uv{3k44es)>LmeF8GKQ5vzW+ z)p(909UNQd=bm>TL*kNkXqGa}&jtz=i*w(OJ;x}@?yI!1pnRDDSj0p+Ffaw=i8es5gUVUH3b z5ud)O2O}V>e*aEn)3YLg#sCgJn~BX~*k^d{vkbstL%}EM;Z!q@+x976VQL@PS~omH z9QT?3-uj_`RTimo?-?=O6XW|51t1j|`dzsu@1>bB@UAmc%iZU&i04SYFRGSm+7Ngc zIj%R~7k~c$i}7#w|H0VI7zJK-3h?Ljva5Ou9tD~LuY2>`fB$1QcYovd?&|Yl_WP)2 z8b#1Mb$y8OfsaSJ-vNU0^~9G2UH}-)U+;vl6!sLVm>q$>q~o$WL-*a!uL3eay-=~Qn#D&W^fT$|%O3&#rLFUGZOM<2{@`J{ zh^P9!fHmun+)R&c*mQnaCRXMJ+7c!i7>veot?7tKe^@|#4Alp#_O^9@tx*^FZDx?T zn9uUd&uH`txA@tJ%)BasK4Zcg^>uyCUp%T6nPlOWCPiS);dHIwbIrKD4n=16;8PF8 zgg(h%Ib|0{`NhUHA{_X8{Zb}D2!v#fj(9P`25V^^d)2b|b*700&G?-kv4WPZupi*e zXQmYvQK%6LAWj0nTmw3>f7`&%HGRpsk1ud04XW!?0tCwb8Pscel}a@VDLbwzA?wFJ z^<9~Sts1r6uGR*|zCM6d^k&RW%h~g%JOagrJtxKW5%U1QaOUS+WyHSDCXB4VpI-*Z z6aYi&HOODBUGRYf-aO1Ps+ZiCt^2(WCzd{Qrq7tB+lD$ZHf-$4rzbD2>EOG-bk-Rk0S*XsNsnVvO>L|znT&9F}^skp!g8}-k%x`sW=A_yH~Fe+Ddwh!=C zV{o#+s;`Bs`Vl>HtzK6CV}1R+TtBO0vl%>N>G^?XiD2!h2Pm1ce+5udqO#YD8T&K^ zNO2IuH6PXnSIQ?Jkp+jJ zdZfT->nx9vI^XvDIEd_XUS)uVvGP+W>?J_+u~4~9asL8Kt0rK56b6-bL8qq4{^8|w z)QlA7GbHNAImMRYu2N!A#T4FOyipdIHmap61j zLj>KU%a5?eV*Sv-J>=iRhC2(;ZLyaAjh{BaP(bw`4X~GP3rO*Ku0c8R+s2UTXLB%6 zn7G_4VDm9Rav}~#6rj2gI4rPx#xneReqgOMM?d0h`Wx5y2nk*StUxB%2y}Xbm6PS=ZwC`{8VjUW}G{*z392XUCUKJNw1R{BSX%1z`j;ecde(Ge&`zvjV)$U(O9o?V~_f;4N?Ynpgebt7pF| z9{hhbp5(utkNmjH{YC|2@t5`i0`o+4&llvxn+yQ*HAMJJuMi zA9F5j?>?+AM~^1Hz87};B`+)%Z5NPGe$)_PoNDEnL}*ZPDV*1AAnmdCw+Z`+4bLY_ zQ+jO_H~olhBGw+ZC7)*7u(@x2L>+*%hsc>XxSi~&9ljlHCw_Lf6FSyS}b zh#wK)h#{2XW8a&^9>X@zwqedJ6cR|)x;V?We6oq1A7=;t5$pbuPZ?Fe&Q+Sx0$mO@ zu@)UPQieMs5ZBhQVfZXPqj!c(4q^ERJ>Y|Pd6tMH9~KH_97*J$O!gH3^nsysfnV*Fcdyot|VK}YMPkc8>A3#p#**46XL+2L&V>}yc(J*>pzwe zzlgQ!OnTZ#KUq6HW(=pVbpmKlKCx*h>A}kj2A)R2ou(&2N|pt7yNS-^8Ft{%RjQ`* z50mY{^q#TbM@CBlw%;F_fvy07t((F2olk$b_$)c_*;DX&wkRUv@;MO;o$LPm&~Hg8 z0r&Gs&z9_&H}K9!MM{Fg+we{08Lk(yt`OO4YA!lz#>&q{<#er)fIebljjH?E>YTtn zvm=%{ia^m?OvQ&Uv8nIMyYk;}TYR5y)b(F)@9zG_t6uf0Z+g#rzTsIQW{d)_1O@nf zcqKG4J&XcffiL-zuY3HbKJ?>X6%YEq67PZ6>D}fR%R+WLtbX|{M7~bqm*NjPy?7)nzs&P&9MJwagzcjK7R@@WOc~|D^HVj<>l>iqH#@So z0^nm!fv-MOZ#u$lKKvjnh1Nbv*S*aM!0_%rHbG{3#8M!pskLpGbGR=3ux!u#5r?b5 z8|PW{@H5pqAK2LwXI=KXb)Lo{0sU$Q3Xl)~8c|myTDVrH3JfR~81Q*b18u6wzQref z;~2A|xTfl(;gw$cAe%AHOk4J3pJ!wGzOU^+KflPVznPNu|Ji9x?=sk-HnJM#OoCsXjf!v6o zbLm*E>q>mXVUNK8abbekcYEd_i1mq2j&TSP#qwM!1cJ5^w$iT`)zpcQxR+4tJdW+&Rw1o!Kn~d>H;s`S*i8hP>8AEFOg%g|I5-*LgWg zks>ZzRIJGGW8wOnuYK)jf7ADV@7Fy?_*_PTSC9hrQ+WmTG_8yRr2zg90YCY{pZMcp zi2wP(yWfoWSiL3lc@Hpu2yx-Nzz|~r@Hn&CcbreKC#GT@eaAh3wqeH5tI#jQt+l`k zy<;39`F!CmzN{Iz#PS~Ao&Qh){5Sxh6>#ywPaji7Y=4aDot*WXkL*>`?t6$|`B{GO zjxT)MF&}RL?A!1MkLTrz&KMea)pm(d{p|7LY`}q3`U4#V>IOv+*$!K5p8VUt%MYZ=x2)l)|B`RE47 zdZHHAl|2~~6rKs@_=lP83qEb;#cV8NF3O7fc>}xJh87*m#eHZ8-ynfh{hLujfYqi~%AN`L{(K(x+NM$8#scu&6*C_{yRD z_U(MXM>7yg^H{-^q}#D(K*g>?jER}`1H(8Zq*qT6ovrx7cFf`n9qCe>zF%jII30ss z)+WI(_&^^qvVn`Cm|Dk`y>vVDzWz@)S9gEwHLv}3-!gyjFQsPj3RK|mYj_2AI4z9= zQsDXxU-mZ)`K#mdLl!SBy`=JW;&;-i(vEuSr16YiwxIi6 z+iiXi@*5#Ge32UK8PRj??6EDI-{aQ8d(VU(_=txX%*zGXE5GZi89`8ZPKdpY@O3_W z4NQG#fR}vmj(ob3nO1C*@lNW7|_QL@)Uic?xtx4|+o=c67F&Cyl1oW;h3k8piN1EV!&xR7-XXoA0G zUK$vOdPqEl0#Bz(FOv`N)j|+;q%VqIgw@u*?-p;>=H$^UJ)>UG*(K zRv0J^`Aa9%Arm^=?iG}28`dt==gdA3Nm43~udAu-b+v3>K^xz+6;rhu%ksU68TeIK zEoBdv%LdHDHqVP?M4K0A8EwniTJ~h_12*;&>~8Z$@~`)g`!>gXC)fF9%#7{GzU?$e zwpg%4ij)z;K$`=G@1lN_Dw)8<*>>coebv7|e|&FWK7S%ml3bV4_xiOy#i0%-bxjvG z2+*Tupd>`ohMSOIbfQi$kij^vjk-nuFQH^?SA*vu*so2vX;D+*&(BS}H*9&jK`zU%(YF zeA@>?@->}(xang;FZR?uHvaIT-TD0LW{${6dqnnO#a^b2iK390#@tYtZcjek9!P4O zZ4pRj1jA6^tyej={#SaeF!Cca_*j9jTtcc;`G7WSt@iw`W6frr;o!qi7R>eGmHog= zn>9_lKAXhsJoC#RywW6!m)-dgJ=lp+H1)0emyZ~L_Zt8ffMh?;M<4do7c>86=bf)w z~pI=&|Z%2gUPzk?N*!qRbTR%pzaApC|50(o*R_@7^0f#<)DU2VRwt0bP=kxW^QL#=KMk_v{b>HFR$1?U0 zCL`B}lRlV?RRr6FR-~=}7F|ro6<;@i*_nR}Al<6BRjlgw2Uc(jlc6=K@A8=+PTPD8 z*rOil6#y@MlVa{;5y@LO6hN6MG0wHqRhzLpMts{mpYiJ)BbWZbh=^`fnwr0?C^5A$ zHfv?uv^iT?(&t*`>CyTh*x`lGqx;MOLbW;O+Jh&)&=2tAOxRl-mU_lf$X40C$N9Mr z%ot^hb(NMl0)(-C#YeWlQ*SO-=&IF4bi3m%tX8Cq3Qu_}! zh_n$ZKRxMFEre#S^lp=7>&Bz)xM$3`FvCgg-pi^Y@hwsJHS>#FI#(yr)`A!2Xp8+i z|A>%&^WC@aL-=9y(D~(WE$rOG=`{mkF9XAuE$lXc*heK6rfS?U_xq0c;~rruMJ)Vm zad_ja)Ly?q(IoFj2P(UQ^S5f-876OF=Q0%Y{=B%{cgzvH`+TH+9wC-T*6%*T%;TV2 znxJeADdFF1*~-B4`#|*k&bzytzxT{D@BA?{XC4JUnG~p>%qLUR)9WbE75I(6^BuqZ zxsTj^)%D%opAGvP&pzDN%ce2)o@s{5j&*qN8F*vGRhHf6sI7t) zV8S`uGYUB4t#@*N2xh0u)}|e_GQNmu0hE zksML8e2mFpipUrx%HpqDvq*Lu{=}-zj7*zw;-OOb^amg1R6eZXG#?q4-*v-^q5blA ztPn&+Oz2%Vf?cl5v%ryDwcv^j#cMeFTu$0$lQBbpt@@)Q_RBiL7+})ikH6S2ZEFoK z3MfAfd0{deT*v`inB_Oaa%kWET7!y#ovyMt zjK?!N*vxbN+-Li=wX-zV`CI5t>nR`ZzsrmWx$kkA3pWRf@c_VvEWFMy_k?mStnWWk z9S2bY<9wXM)}pU9Vqt{Se$TYGyxrc{#3_WUx9R@51<6(z8Ei1UzmNSM$9oSR z41NgY=JM4je#b3*`b;7yE~YSM!@zGJmQf6}a6g6?i>CfbN-H7A47`g^t}}F!$F;by!mp+`nPrK zQI!T$5y%gM+S;Ru)homr7z`G%3J7z3xUqoqSUSJVu0oaO!0-2hby+nA<#5i+7k#mx z%TOAb6!WiYRq0`V#UaMmfc zNe*DdfqWc|zg)~xtt|WH%Ce%KH7@yQr7(3mOgHZ6<9;P*D>IfU6PDn?5f-ZYHv>l_XG?^yMc~;0nYjSod;n(KZM40t3R5)4}*g3>sjjj z5GzG zfj7MA?Z5Ksv8z83|H8m`#Lwin7@(KY!#|sR7n`!rdaKdqC@^9871m#X-A+)x@LCwW zQ|pJUwIcaFe|+zI&v>}>05oPLuw$GRT8;oEpR8E(V*p0jV;uGHo*xte>K|e24+OH` z$5&1jMotH^!Ojm{iosG@Xch)u7b@1O|I!X;VTf^+9Bfy-w8!Y}y0Q`ZJC6Y4y8jP}qy&)`#i|KMP^{ zb>^9q^&x%gf_>Ja@^ha!`r7(=&oATg{Fsta^FEE78EdOQ^P^zK56ViJTCl8hK59() z>f9+mjmocm1}-}@;u3?#n58eSvf%2wM0KfLdFwnA?Y*KP#(^KvFkG?m%ob4VBb0E; zZj0va{9sBAj{NQX0#VAgCCW}3rpxB0dc8%Cc8>=@{cON326xbZg!d|tzlH2QFXZ?k1W zbGq*WTT#|R^_jiwT(tm>Yb5q^YvnUL)g!RF=i)96lC(uw^I><`Oh$~py zNr($D9#v>|kO#oNW6k>ugyiUcTLDsD;t;a`PPop3b^oFM=*waR9{W}2&Oh1_zwpR~ zJx@DUnTH$!8CUsWaLsNQHVo|*2Pom#f2@`7(k`y@gVXs^vY+41uN}*FJz~M9nvkKb z@9y3g+jrhPe)Y}oeeb*exAV_D3XB3$z`vl;V-y$#?or@%Z+gcU-rU^2J=}jZ{`nR6 zz%I;wO~KLEM_~E*a?>lUbKuADs|_;-d7abN<_LLm&0zKM6nmOn3n9}&JAz?9j$VKB=|f1x_H~1Bx%XM^@k6LS z?m{em^fZ645&evhl6HZyPvNlSaLnGo*;>(@ZN&*YHBAnSS^QNOR$!@G@?&XDIKy|a zsYY|K)7l@iosT-`-EV;6Y_X1KY7RhN$IP0uKds@9e-3=tA$n*^fA9mFbhxy}%t{d| z^$%e6WE&DO7peO?`7r995K!uvbl_(gO+aEaY>SBs;j_0RvDWY(Bt66G_4+tZ@HQ!X zVnZP4hrxTDYvtca$BiKu0!OU$-CMs3$o7?=M+PDfEs_Pxc%<%f-D>HNT-6#)Bj7PuY>hBz{bKbMI3c}A}ebNOpYFVOq19V$Aeqm2+d_Lp>^tBJbXSzRs z`k6(}et=lVxJZ?OU`LfToB)QtE`S%UH=9WDb1kZ8Db8$=cS#M;~8u zh%6*trC52-WnAF2eL-fk$(Mwkx7_`PF4U6BZXa* z|#& zWnT=2y&GHWRj2UYuP`SZMv{HuQs-lq)S0-$8ah1R=;z$m+xfL1y&jskbB>ywv)2M( zwrRd+-2<_sA$yMXRtxAYv}0lJ=R^(ynV)OoQ3G;dWG%9L(;UFT9K+|O-P#}R_LCEr zq;GttXKgCQn9eztxCQ7ytYe&1FGpQlGhyTJysCR3Kn6bIy|1Vz1<)6E-3mf3STR{) zKiBhh;wymp&5lSm_jTA@uZ8lSGZK0PBO( zwgOt~eslFT!?~VTF!F3;`FbyacyApxT5jxP{VA+!lVw#J^@jr`Vyv~_AF!@kV*TMT z{HyEhyMOlVvmg35Km5br_E8{ai~^&;!wUG9_b_U_qrfPzDS&@*;G>V+etEo|{!qMt zes4H`SqE&dr*Pm!hhaPCe96&=OBlN!c$xc)&$j+z)VlZ?7krJ*j|_aHUhlfPF$F zENdp^a$O%Y5%2tijnzVd_Hm^A{sRSlDOgq)yNCx4?I=Z^$%cLQ1zgm<>%9B11K%;R zTWe`T{gFdyh=5j#3v=?JQS{2}>34o$$Ok+1DEE*K}|Fh?B$E)B4dpWs^Sc1GPDV^(CK1)lF>j*-NacEqV$F*Q^0Of`}Po<${fH z)+?Oy2}d}2Ix|`G{4%CkX^S}QmqZqe3|WivyAsuB+s=}hud-6MisMvUp5#M2)gLJIfl>8qD_*R7pMg0J z%EGv+1$|(cf1Ksbu!%!%&^=UZRVi2e0ww8&-rngO<)XZ3RhVLoaeSbyH;OqX2 zMaD{5STpc4`QbMqfB|;C5?}JcsKVO;utpXOw_;h;@_~zyX<>I=g$a}S zws|HO^uSn`BOeg~@V)FAgp`kA)G`8L8y5$~*6ZU89|y9S14I56Q()KYGxbbrT)fAO z;pc{CSfYN!Y_m}CJs)2&S>M(5N234lzP`Ts7x6Fj`;Pe+`bodJjsh=60so?23}>*T zz$nlZc*9%&_@`e#di9sx-QImgyjOo;ygr|7ZZ7k+x|zGp>$BM;zRzO=jE6oJyu<7W z@tVx{czF?^li|3qE&he?w15_q`TT*T*!<2&NPa_{euD(0K795MJa{nj!$-@in>}u} zsNacb&%Hl*U^^S>_Xo~`NRLZ9D$>UcuwENy74;z`k@cA-tQ+VbMz(48Zbh;k5$t`~ zrpj>D=m)5pBs#p=XY~~O(2@Pvn_^e5z$OL}jvsd2M?bJwKu2YBXV|1d~^^!M<=UD64FP_(I{G>o4zx)L3L z(Def!g)x$&irbV&5xW{b!%o_{z;W1@b1H_0xxk9H=wR4Qw95R2+igo#(4Jvzx;uaM z=<3*fC3V!S$sqsaCaswr)6phAVEjoyQpesbeNXfUPt9Rb8BvxESv6kcVnA0U{ z4OGncJ^MlaM?Mtg%>w_#1xb6nglqTd8$PFBw)to54WBjQmU~Xh1Dz6ZL#XTxtw75a zIG`V~tT{n0TF1NXM<(Ai*O1_BMtXfc$qzLhhei?vX}D^*E7enzwO~=T31^U=mX0=| zS;Au+j;Cc8!&xt}(ovk)z&f_R@t`cU6ya&S<=#}RH`1`+ltyd0w<<3OQERR zXjWPtUZ)o~r| zkLM$u`?w5N!Xa&ubQcVtalwfUpIG5zVHYqQWR zlNSZ=QT$&{6K|C%S)}u|G?4B>Y#vBnjBf0=4 zsLtxn_t-vgZ$h32l*Gn8R{2okpUHnOIEpQ;6gTMe2Q126Gw0#1faj~EWyA%!^g5F+r&wdPi|6fH)-s3z`h0P%V?v1be=#Wzw~Lu!3R6P06?H zT1mJT=}_0CuZXmVbT-f(enRdTvatMm`Cg_Qc+QOdiWDK23#&kwrR&+cP98aoJfkyl zJRL=!O$F^&PcZ2d(aaiVqNS?{1*n9>%yh`~MFLq%7hx{&UNv^LdTe|QG1TizBXAZK zH!6*X??BFV+B!A9B6Qy}rj_lEULub~U`H9vD~FK!v)zMFycQb|s3dP=8Y8e8ttA>k zsJuJX(0k3Y2Esx6g%{HU3`Yy<@}oBH&q&Bywc~RSeb~N|qFZ^bpoj0}rh;k8Th8sa zfWko8Y(_~5fhT{Nt0<^SbD0C%8_#MM)zT70#X4NzLyC1qv&a*5~Nd3P-Ax&sKz3ZLjt;ELhTjC+*|NIWzrRAM7F4gVg5mXo4 z7>oH0d-9Mox7_#}TR&AUlv~NAT%DVmcZ4;Uml*3NJr5kPSnD37yTk}vV010I{!daU ztG*AEvh+6Q%B+doApb!{N<1ID#FFWh?@cUMe)dC_`(vQB*8v+;lUwm1f8U`d5A;)> zuzYV0GDyI8@09Clr8jAaVlbao(>+|X`U7{1*{;S{!V1&Exx=T%jMAJmfWE%|J10CM zIY3ycBSSeBzM(Eu-pE98j6BtF3+#k`xUJYgj2uhU6ea_0a0_<4=%E z<`-p^4ypMc5!&oPFH}yIM4aY&YFAcF!m+B8RJ+R{Jf0kO#4Rr)Yin z_oHPa6(E`w$2L$ZhMw2GQ~Y+kV8A}4u^=|DZa?u(zZ{}xEht)pUZEJ>V3iu{ffWto ziZQMHz-IMv750bq z9vlpE$6$`m_~ONPWED*X6%u^>YgCN%`@DOAUdY%i*kh{x>sB6>niv|4=z3!?X-w36 zX@c?qz~5%W{K*DestOZ-_!4||&2dDM0l-cOdXWs}o-9hz z$>5pgekGsk)6%`FP$dL7sDYE8yn3F9f0pO+hUBv>EXf)njEc@W^vT* zxg3)Msyh2o0@ZCHc%fr>;?$YbB@Y1F3{PpK#D&IEn$SgQv^2j5|A@4MFtOA^5(SSd z+D|5BlV;jL>KDi}Do^jKvW~^bNPw4l5=y^v13R7|P6qqb>DE5Nx3l1#ACOVfM;|v| zva%MBBF32=U8Mz4d3>qO;!JzQz9QTt*yU5)3H}7ZT~#>^}WqP zPPrBuJeko`?kmoR%z!Ea|Qa9s%di5BKQ`(6MFSero6a)41IOvh1t-;pf8T-kG#C zdsEyOYqKliL$@ja++d4J$20BJd~_Z&6()%MAH7mCAu>34yH_#Y(@2GX#2{qkk~c-i zD!>24se#B>n1nNfmE6z3GVm{%zAZfqUNZf!N=~2g!H+pXnYSuv>(dj%OqTKd?)(9g z62shNvir5Of&~sgK1rUNNe?VbVu{UL4($9nD0wz#968H3bDcIglAb>i5EQaAh@(3o zZnd6$2@-#|sj`bEJZW7abu|?y%>RZN0xpZB2xSnb?*?=xG?*byWC2GSlHXcvkp(Zi z8R-dChzC%eHzJdoLkqFA*m#i>MS_I7Jr2%{^+jsob=SwG_VWIzB`m4aBj)Mw2)ftfNPk3;TG zdDEMg&90lUc|if&@c$TE5jQ%Z^oYv7CI(v>8X$1k$Lw0`Et`8kG_y6qwxDh)He~p? zQCiO!DM-5K)H;$o_Lffm@ph6t$f@5|-y@*dUkd$cW>LF+=SNzlIF7^GV!#*LlQM%_ zV%Yue*0UDcm42Pt5V@(G;@dH#?$g0SeTu2A-*@X8iX+liy#+9&$|~Ae6tY78h5Gl; zaas^XjuhQ~a+#{KjqlM3f10pyh+zj(nU?KYI++V#yd z$lKpspkld_GJEI`?Q@dE;=61!AjWX|5q}gP#oX1YhgQR$<)-37S!&ti5?YkE4-OaH z+Kez`51F_`>%xmPUBV?ggSoDNe)IlL{n(77UUC7=9*RGfp?*Qa%D4SjhS*3J09Kv> zv%f0Ob+Xw~%8?wO6&fb7G^7DOvkgK$;5OJW4rXjcIWNS- zhJP<d7djW;sjKlKlBuRp+BO;e*8+ZTQZFakB7S`M`-Umz7i~ z2%nu8ch(tN$ymD(^?LGsJgx|vWsqL7>?dXYEb}I~RmRZMRsH>7xo_HG^Ao#3Go-N0 zVYXv=7K2}0dlzafaCHQUdY?wY)sba)^5yIp=)v+qN6{=F=aas z^R%zj%f^>fSvP#OU6mJ^iFC#b24mD<-cMk^RMQM7{;9YtrJK@xBY`#)15(C+cpH78 z52!si%tMp?4PE{O-tF*WiB}Vi8Q5`4$+vU1{yV&q(VSOUrKIe|lh;b;BEGkMkY6bw z#5er%y|#;%j#HOTB#p>0>}ce1%K8R&Z+ZDA%+C*mT0a=yOM*yMiL=V)4WT=1*zJFx z&RelDEdZFU6=Ces^6q|;t@b8c7_kN@izZRlMR?dTrPOyK@KgdRjn#@y%AiuW3M`ipy&P*m^xEICk6TYOE$ zT+!21-BLyP<>uV9V@DkB(2wC@uD4u(UD1_%3VrEmW4zM@yX&Xz+8-migzL!H9jkI* z)JB?!BiPoh9OBwa3WI#ePV9P}B(AckIvqvNiJ;GxGGjbGD|4w6fGb zLEEprFQ<-PFq^dFyzFJJ#j)Y{C1r@e@>{Dz1F{O%W`fyf47pJ{su&1ofrESg zb_23d!DH9Msi(@_z9s6%QMgHO?X9iM!AdL^j_*iB@iEa+qD%!w_W?wxlF`{awu3gH z&hjwGG|BxCXD@BuZ~ez{scwIg&$sg;HOLl6>^{L|4E&TnFt#fBXzdaBU~O%;Ji8KK zSKr83A=T&O6IC&ast$8vW_Hx1G8bCw(7Yf9e)qoKf&Xtafkueh_L#inZiPM+$u(d7 z0t-*!EW0kA%I-lBb2_;ctp2E+9bJmx|1Q#gAtEyT83817zpHy!-ArU?X@}=+MK0QK zPc(PmXkPEZwtK@uh?FBCD;ngp2h+7SJwOIP2IACm zvYvd*3OIKgK2S>*55TgYkeQs?Nt(3hQU>j|y3J2m;VEgsHd{FwzRUf{*WsaV^Bumx z(fm;VHSkIy-{8www?ea?A~c(mkGz`sTu&6$T>IsCu+~*2-pa(!ABg(#UNS?DBFnI5 z1B!v(xE8+crmrumsXXE!QR1hj?bOuS^nj+EPb@D0&-6y8j#t` zww?X0J&|3&bl5uoT4UyLN=ucfA0F4MM0I1m3fFhh&sGT4y6VMxlz9XSd)t#jLlcvf9cp1dRo zqcz+OloAtn4-aOXN2G?n4wdK>PNDtF^1wvB+)W0S37*$%KVnmkVOeLnv)*vE*d`UpX<)#`)1Q z)+uv%1Z7{NNV=YGjW&F`EgoWvrsq+>p}ya?p~B41&j_uk=j)oA)2Qn!kaLX1%~lpK zk-AxjIzO*iv{nR_0v$rPb!^XU9@(&iUucEU8h`RUL zr>_nzze-rbPu?|9F(Y&g`hU!Mnsu{x0G%r+o5@ZMa zpx_{!jc;=KZn7_albIIAn+k%c>`FO@qnk3bV9CRzm|XCRs$*Dw#Yza5ofnt1PG|e) z%EJlHx6&j>%1nRgJZbFfwPUkU&HZ}yvfWe7?rQfJ4p?&;_s19K-dKPKF5aKNx~^3? z{+fDUe@$FNrN>|Bz)oTpn4e8Dd>hL|OSlu%LE3l+p_dXqK6j{o*!*J=$gB?_Ju@#IS_1o`6=}!G= z9;66*x|*tFfH$F{Imkb8lg!&_W)U49LvN|JC4hLR@$D!vrdP{exoi{OG?(&b-I-Y& zI>Fp7*Vbl|YBBW(OaH + + + + + + + + + + + + diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 056879afd..f811cd69b 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -267,20 +267,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); }); - it("switches desktop packaging product names to nightly for nightly builds", () => { - assert.equal(resolveDesktopProductName("0.0.17"), "T3 Code (Alpha)"); - assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "T3 Code (Nightly)"); + it("keeps the Jones Code app name across release channels", () => { + assert.equal(resolveDesktopProductName("0.0.17"), "Jones Code"); + assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "Jones Code"); + assert.equal(resolveDesktopProductName("0.0.44-preview.20261002.36963972634"), "Jones Code"); }); - it("switches desktop packaging icons to the nightly artwork for nightly versions", () => { + it("keeps the JC Mac icon while selecting channel artwork for other platforms", () => { assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), { - macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, }); assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), { - macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, }); @@ -692,7 +693,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "**/*.map", ]); assert.deepStrictEqual(mac.dmg, { - title: "T3 Code (Alpha) 1.2.3 Installer", + title: "Jones Code 1.2.3 Installer", background: "dmg/dmg-background-latest.png", window: { width: 640, height: 432 }, contents: [ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index ee34ea032..4102b8a75 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2612,14 +2612,14 @@ export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { if (resolveDesktopUpdateChannel(version) === "nightly") { return { - macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, }; } return { - macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, }; @@ -2642,10 +2642,8 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { return `${trimmed.slice(0, versionSeparator)}/${trimmed.slice(versionSeparator + 1)}`; } -export function resolveDesktopProductName(version: string): string { - return resolveDesktopUpdateChannel(version) === "nightly" - ? "T3 Code (Nightly)" - : (desktopPackageJson.productName ?? "T3 Code"); +export function resolveDesktopProductName(_version: string): string { + return desktopPackageJson.productName ?? "Jones Code"; } export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( diff --git a/scripts/export-jones-code-icon.swift b/scripts/export-jones-code-icon.swift new file mode 100644 index 000000000..214dc5be3 --- /dev/null +++ b/scripts/export-jones-code-icon.swift @@ -0,0 +1,90 @@ +import AppKit + +enum Segment { + case move(Double, Double) + case line(Double, Double) + case curve(Double, Double, Double, Double, Double, Double) + + var svg: String { + switch self { + case let .move(x, y): return "M\(x) \(y)" + case let .line(x, y): return "L\(x) \(y)" + case let .curve(a, b, c, d, x, y): return "C\(a) \(b) \(c) \(d) \(x) \(y)" + } + } + + func append(to path: CGMutablePath) { + switch self { + case let .move(x, y): path.move(to: CGPoint(x: x, y: y)) + case let .line(x, y): path.addLine(to: CGPoint(x: x, y: y)) + case let .curve(a, b, c, d, x, y): + path.addCurve(to: CGPoint(x: x, y: y), control1: CGPoint(x: a, y: b), control2: CGPoint(x: c, y: d)) + } + } +} + +let letters: [[Segment]] = [ + [.move(246, 616), .curve(246, 710, 424, 710, 424, 604), .line(424, 346), + .move(318, 346), .line(424, 346)], + [.move(756, 398), .curve(670, 312, 546, 350, 546, 512), + .curve(546, 674, 670, 712, 756, 626)], +] +let paths = letters.map { $0.map(\.svg).joined(separator: " ") } +let svg = """ + + + + + + + + + + + + + + +""" + +let repo = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() +let output = repo.appendingPathComponent("assets/jones-code", isDirectory: true) +try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) +try svg.write(to: output.appendingPathComponent("jc-mark.svg"), atomically: true, encoding: .utf8) + +guard let image = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0), + let graphics = NSGraphicsContext(bitmapImageRep: image) else { + fatalError("Could not create the icon bitmap") +} +let context = graphics.cgContext +context.translateBy(x: 0, y: 1024) +context.scaleBy(x: 1, y: -1) +context.saveGState() +context.addPath(CGPath(roundedRect: CGRect(x: 72, y: 72, width: 880, height: 880), + cornerWidth: 200, cornerHeight: 200, transform: nil)) +context.clip() +let colors = [CGColor(red: 16/255, green: 43/255, blue: 75/255, alpha: 1), + CGColor(red: 37/255, green: 32/255, blue: 79/255, alpha: 1)] +guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, + locations: [0, 1]) else { + fatalError("Could not create the icon gradient") +} +context.drawLinearGradient(gradient, start: CGPoint(x: 72, y: 72), end: CGPoint(x: 952, y: 952), options: []) +context.restoreGState() +context.setStrokeColor(CGColor(gray: 1, alpha: 1)) +context.setLineWidth(76) +context.setLineCap(.round) +context.setLineJoin(.round) +for segments in letters { + let path = CGMutablePath() + for segment in segments { segment.append(to: path) } + context.addPath(path) + context.strokePath() +} +guard let png = image.representation(using: .png, properties: [:]) else { + fatalError("Could not encode the icon PNG") +} +try png.write(to: output.appendingPathComponent("jc-macos-1024.png"), options: .atomic) +print("Exported Jones Code SVG and 1024px Mac icon to \(output.path)") diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 5bbcb0ef2..83b8e9c39 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -1,4 +1,6 @@ export const BRAND_ASSET_PATHS = { + jonesMacIconPng: "assets/jones-code/jc-macos-1024.png", + developmentIconComposerProject: "assets/dev/app-icon.icon", developmentIosIconPng: "assets/dev/blueprint-ios-1024.png", developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png", From 670d4c853148460f6d9e1fa583bd739ca701112d Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 05:38:06 +0200 Subject: [PATCH 20/59] feat(orchestration): persist observed provider runtime identity --- .../Adapters/ClaudeAdapterV2.test.ts | 1332 +++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 472 +++++- .../Adapters/CodexAdapterV2.test.ts | 957 +++++++++++- .../Adapters/CodexAdapterV2.ts | 673 ++++++++- .../EventSink.runtimeIdentity.test.ts | 404 +++++ apps/server/src/orchestration-v2/EventSink.ts | 190 ++- .../orchestration-v2/ProjectionStore.test.ts | 156 ++ .../src/orchestration-v2/ProjectionStore.ts | 30 +- .../src/orchestration-v2/ProviderAdapter.ts | 124 ++ .../ProviderEventIngestor.test.ts | 243 +++ .../orchestration-v2/ProviderEventIngestor.ts | 48 + .../ProviderSessionManager.test.ts | 360 ++++- .../ProviderSessionManager.ts | 215 +++ .../ProviderTurnStartService.test.ts | 44 +- .../ProviderTurnStartService.ts | 31 + .../RunExecutionService.test.ts | 418 ++++++ .../orchestration-v2/RunExecutionService.ts | 11 + .../testkit/ThreadFork.integration.test.ts | 104 ++ .../server/src/persistence/Migrations.test.ts | 42 + docs/internals/provider-runtime-identity.md | 47 + packages/client-runtime/src/state/models.ts | 2 + .../src/state/threadShell.test.ts | 29 + packages/contracts/src/index.ts | 1 + .../contracts/src/orchestrationV2.test.ts | 28 + packages/contracts/src/orchestrationV2.ts | 3 + .../src/providerRuntimeIdentity.test.ts | 59 + .../contracts/src/providerRuntimeIdentity.ts | 64 + 27 files changed, 5930 insertions(+), 157 deletions(-) create mode 100644 apps/server/src/orchestration-v2/EventSink.runtimeIdentity.test.ts create mode 100644 docs/internals/provider-runtime-identity.md create mode 100644 packages/contracts/src/providerRuntimeIdentity.test.ts create mode 100644 packages/contracts/src/providerRuntimeIdentity.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 2e237bf3b..b989e52f8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -10,6 +10,7 @@ import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-to import * as NodeServices from "@effect/platform-node/NodeServices"; import { ChatAttachmentId, + CheckpointId, ChatFileAttachment, ChatImageAttachment, ClaudeSettings, @@ -20,9 +21,11 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, ProjectId, + ProviderDriverKind, ProviderInstanceId, type ProviderApprovalDecision, ProviderSessionId, + ProviderThreadId, ProviderTurnId, RunAttemptId, RunId, @@ -63,6 +66,10 @@ import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, + type ProviderRuntimeLifecycle, + ProviderRuntimeBindingError, + ProviderAdapterProtocolError, + unobservedRuntimeIdentity, } from "../ProviderAdapter.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; @@ -7907,6 +7914,1331 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); + const makeRetirementHarness = ( + options: { + readonly holdTerminal?: boolean; + readonly holdFirstClose?: boolean; + readonly sameNativeId?: boolean; + } = {}, + ) => + Effect.gen(function* () { + const harnessScope = yield* Effect.scope; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-retirement-", + }); + const sessionScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const terminalOffered = yield* Deferred.make(); + const consumeTerminal = yield* Deferred.make(); + if (!options.holdTerminal) yield* Deferred.succeed(consumeTerminal, undefined); + const terminalReceipts = yield* Queue.unbounded(); + const abandonmentStarted = yield* Deferred.make(); + const releaseAbandonment = yield* Deferred.make(); + yield* Effect.addFinalizer(() => + Deferred.succeed(releaseAbandonment, undefined).pipe( + Effect.andThen(Deferred.succeed(consumeTerminal, undefined)), + Effect.asVoid, + ), + ); + let abandonmentMode: "normal" | "failure" | "held" = "normal"; + const chronology: Array = []; + const events: Array = []; + const queries: Array<{ + readonly input: ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput; + readonly queue: Queue.Queue; + readonly exited: Deferred.Deferred; + readonly closeStarted: Deferred.Deferred; + readonly releaseClose: Deferred.Deferred; + closeCount: number; + }> = []; + const forks: Array<{ + readonly sessionId: string; + readonly options: unknown; + readonly threadId: ThreadId; + readonly providerSessionId: ProviderSessionId; + }> = []; + let nextGeneration = 0; + let nextNativeThread = 0; + const lifecycleCalls: Array<{ + readonly owner: string; + readonly method: "reserve" | "bind" | "abandon"; + readonly generation: string; + }> = []; + const boundRows = new Map< + OrchestrationV2ProviderThread["id"], + OrchestrationV2ProviderThread + >(); + const makeLifecycle = (owner: string): ProviderRuntimeLifecycle => ({ + reserve: () => + Effect.sync(() => { + const generation = `retirement-generation-${++nextGeneration}`; + lifecycleCalls.push({ owner, method: "reserve", generation }); + return generation; + }), + bind: (binding) => + Effect.sync(() => { + chronology.push( + `bind:${binding.runtimeGeneration}:${binding.providerThread.nativeThreadRef?.nativeId}`, + ); + lifecycleCalls.push({ owner, method: "bind", generation: binding.runtimeGeneration }); + const row = { + ...binding.providerThread, + runtimeIdentity: { + runtimeGeneration: binding.runtimeGeneration, + evidenceRevision: 1, + requested: binding.requested, + observed: binding.observed, + }, + }; + boundRows.set(row.id, row); + return row; + }), + abandon: (generation) => + Effect.gen(function* () { + chronology.push(`abandon-start:${generation}`); + lifecycleCalls.push({ owner, method: "abandon", generation }); + yield* Deferred.succeed(abandonmentStarted, undefined); + if (abandonmentMode === "held") yield* Deferred.await(releaseAbandonment); + if (abandonmentMode === "failure") + return yield* new ProviderRuntimeBindingError({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + detail: "Synthetic retirement failure", + cause: "not committed", + }); + chronology.push(`abandon:${generation}`); + }), + invalidate: () => Effect.void, + }); + const makeAdapter = (instanceId = ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID) => + ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path, + idAllocator, + queryRunner: { + allocateSessionId: Effect.sync(() => + options.sameNativeId || ++nextNativeThread === 1 + ? WAKE_NATIVE_SESSION + : "retirement-other-native", + ), + open: (input) => + Effect.gen(function* () { + const queue = yield* Queue.unbounded(); + const exited = yield* Deferred.make(); + const closeStarted = yield* Deferred.make(); + const releaseClose = yield* Deferred.make(); + yield* Scope.addFinalizer( + harnessScope, + Deferred.succeed(releaseClose, undefined).pipe(Effect.asVoid), + ); + if (!(options.holdFirstClose && queries.length === 0)) + yield* Deferred.succeed(releaseClose, undefined); + const query = { input, queue, exited, closeStarted, releaseClose, closeCount: 0 }; + queries.push(query); + return { + messages: Stream.fromQueue(queue).pipe( + Stream.ensuring(Deferred.succeed(exited, undefined)), + ), + offer: () => Effect.void, + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Effect.gen(function* () { + query.closeCount++; + yield* Deferred.succeed(closeStarted, undefined); + yield* Deferred.await(releaseClose); + yield* Queue.shutdown(queue); + }), + }; + }), + forkSession: (input) => + Effect.sync(() => { + chronology.push(`fork:${input.sessionId}`); + forks.push(input); + return { sessionId: `retirement-fork-${forks.length}` }; + }), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const adapter = makeAdapter(); + const threadId = ThreadId.make("claude-retirement-source"); + const runtime = yield* adapter + .openSession({ + threadId, + providerSessionId: ProviderSessionId.make("claude-retirement-session"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + runtimeLifecycle: makeLifecycle("source"), + }) + .pipe(Effect.provideService(Scope.Scope, sessionScope)); + const source = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const consumeEvents = (ownerRuntime: typeof runtime) => + ownerRuntime.events.pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "turn.terminal") { + yield* Deferred.succeed(terminalOffered, undefined); + yield* Deferred.await(consumeTerminal); + yield* Queue.offer(terminalReceipts, event); + } + }), + ), + Effect.forkScoped, + ); + yield* consumeEvents(runtime); + const openSibling = ( + owner: string, + siblingOptions: { + readonly freshAdapter?: boolean; + readonly instanceId?: ProviderInstanceId; + readonly threadId?: ThreadId; + } = {}, + ) => + Effect.gen(function* () { + const scope = yield* Effect.acquireRelease(Scope.make(), (owned) => + Scope.close(owned, Exit.void), + ); + const instanceId = + siblingOptions.instanceId ?? ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID; + const ownerAdapter = + siblingOptions.freshAdapter || siblingOptions.instanceId !== undefined + ? makeAdapter(instanceId) + : adapter; + const ownerThreadId = + siblingOptions.threadId ?? ThreadId.make(`claude-retirement-${owner}`); + const selection = { ...CLAUDE_TEST_MODEL_SELECTION, instanceId }; + const ownerRuntime = yield* ownerAdapter + .openSession({ + threadId: ownerThreadId, + providerSessionId: ProviderSessionId.make(`claude-retirement-session-${owner}`), + modelSelection: selection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + runtimeLifecycle: makeLifecycle(owner), + }) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* consumeEvents(ownerRuntime); + const row = yield* ownerRuntime.ensureThread({ + threadId: ownerThreadId, + modelSelection: selection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + return { runtime: ownerRuntime, row, close: Scope.close(scope, Exit.void) }; + }); + const start = (row: OrchestrationV2ProviderThread, ordinal: number, ownerRuntime = runtime) => + ownerRuntime.startTurn( + makeClaudeTestTurnInput({ + threadId: row.appThreadId!, + providerThread: row, + now: DateTime.makeUnsafe("2026-09-01T00:00:00Z"), + attemptId: RunAttemptId.make(`retirement-${ordinal}`), + text: `Retirement prompt ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + }), + ); + const complete = (index: number, ordinal: number) => + Effect.gen(function* () { + const query = queries[index]!; + const nativeThreadId = query.input.options.resume ?? query.input.options.sessionId; + yield* Queue.offer( + query.queue, + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: `retirement-cursor-${ordinal}`, + text: `Answer ${ordinal}`, + }), + session_id: nativeThreadId, + }), + ); + yield* Queue.offer( + query.queue, + claudeSdkFrame({ + ...makeResultFrame({ + uuid: `retirement-result-${ordinal}`, + result: `Answer ${ordinal}`, + }), + session_id: nativeThreadId, + }), + ); + }); + const end = (index: number) => + Queue.shutdown(queries[index]!.queue).pipe( + Effect.andThen(Deferred.await(queries[index]!.exited)), + Effect.andThen(Effect.yieldNow), + ); + const fork = (row: OrchestrationV2ProviderThread, target: string, ownerRuntime = runtime) => { + const turns = events.flatMap((event) => + event.type === "provider_turn.updated" && + event.providerTurn.providerThreadId === row.id && + event.providerTurn.status === "completed" + ? [event.providerTurn] + : [], + ); + const providerTurnId = turns.at(-1)?.id; + return ownerRuntime.forkThread({ + sourceProviderThread: row, + sourceProviderTurns: turns, + ...(providerTurnId === undefined ? {} : { providerTurnId }), + targetThreadId: ThreadId.make(target), + }); + }; + const claim = (row: OrchestrationV2ProviderThread) => boundRows.get(row.id) ?? row; + return { + runtime, + source, + queries, + forks, + events, + chronology, + lifecycleCalls, + claim, + openSibling, + start, + complete, + end, + fork, + terminalOffered, + consumeTerminal, + terminalReceipts, + abandonmentStarted, + releaseAbandonment, + setAbandonmentMode: (mode: "normal" | "failure" | "held") => { + abandonmentMode = mode; + }, + close: Scope.close(sessionScope, Exit.void), + }; + }); + + it.effect.each([ + { + title: "retires a naturally ended query before fork at its source cursor", + holdTerminal: false, + }, + { + title: "keeps an exited generation bound while terminal consumption is held", + holdTerminal: true, + }, + ])("$title", ({ holdTerminal }) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness({ holdTerminal }); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Deferred.await(h.terminalOffered); + yield* h.end(0); + assert.isFalse( + h.chronology.some((entry) => entry.startsWith("abandon:")), + "raw stream exit must not reject queued terminal evidence", + ); + yield* Deferred.succeed(h.consumeTerminal, undefined); + const terminal = yield* Queue.take(h.terminalReceipts); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type === "turn.terminal") + assert.equal(terminal.runtimeEvidence?.runtimeGeneration, "retirement-generation-1"); + const target = yield* h.fork(h.source, "claude-retirement-target"); + assert.deepEqual(h.forks, [ + { + sessionId: WAKE_NATIVE_SESSION, + options: { dir: "/workspace", upToMessageId: "retirement-cursor-1" }, + threadId: ThreadId.make("claude-retirement-target"), + providerSessionId: ProviderSessionId.make("claude-retirement-session"), + }, + ]); + assert.equal(target.nativeThreadRef?.nativeId, "retirement-fork-1"); + assert.isBelow( + h.chronology.indexOf("abandon:retirement-generation-1"), + h.chronology.indexOf(`fork:${WAKE_NATIVE_SESSION}`), + ); + yield* h.start(target, 2); + assert.equal(h.queries[1]?.input.options.resume, "retirement-fork-1"); + yield* h.close; + assert.equal( + h.chronology.filter((entry) => entry === "abandon:retirement-generation-1").length, + 1, + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect( + "coalesces overlapping live-query retirement and does not abandon it again at scope close", + () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness({ holdFirstClose: true }); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + const first = yield* h.fork(h.source, "claude-live-fork-one").pipe(Effect.forkScoped); + yield* Deferred.await(h.queries[0]!.closeStarted); + const second = yield* h.fork(h.source, "claude-live-fork-two").pipe(Effect.forkScoped); + yield* Effect.yieldNow; + assert.equal(h.queries[0]?.closeCount, 1); + yield* Deferred.succeed(h.queries[0]!.releaseClose, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + yield* h.close; + assert.equal(h.queries[0]?.closeCount, 1); + assert.equal( + h.chronology.filter((entry) => entry === "abandon:retirement-generation-1").length, + 1, + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect("withholds both overlapping forks when the first retirement fails", () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.end(0); + h.setAbandonmentMode("held"); + const first = yield* h + .fork(h.source, "claude-overlap-failed-one") + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(h.abandonmentStarted); + const second = yield* h + .fork(h.source, "claude-overlap-failed-two") + .pipe(Effect.exit, Effect.forkScoped); + yield* Effect.yieldNow; + h.setAbandonmentMode("failure"); + yield* Deferred.succeed(h.releaseAbandonment, undefined); + assert.isTrue(Exit.isFailure(yield* Fiber.join(first))); + assert.isTrue(Exit.isFailure(yield* Fiber.join(second))); + assert.lengthOf(h.forks, 0); + assert.equal( + h.chronology.filter((entry) => entry === "abandon-start:retirement-generation-1").length, + 1, + "the waiting fork must not retry an unconfirmed retirement", + ); + h.setAbandonmentMode("normal"); + yield* h.close; + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each([ + { title: "delayed G1 retirement preserves G2 on the same native thread", sameNative: true }, + { + title: "delayed G1 retirement preserves G2 on a different native thread in the same session", + sameNative: false, + }, + ])("$title", ({ sameNative }) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness({ holdFirstClose: true }); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + const retiring = yield* h + .fork(h.source, "claude-old-generation-fork") + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(h.queries[0]!.closeStarted); + yield* h.end(0); + const replacement = sameNative + ? h.source + : yield* h.runtime.ensureThread({ + threadId: ThreadId.make("claude-retirement-other"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* h.start(replacement, 2); + assert.equal(h.queries.length, 2); + assert.include( + h.chronology, + `bind:retirement-generation-2:${replacement.nativeThreadRef?.nativeId}`, + ); + yield* Deferred.succeed(h.queries[0]!.releaseClose, undefined); + assert.isTrue( + Exit.isFailure(yield* Fiber.join(retiring)), + "G1 retirement is not permission to fork through a replacement G2", + ); + assert.lengthOf(h.forks, 0); + assert.isFalse(h.chronology.includes("abandon:retirement-generation-2")); + yield* h.complete(1, 2); + const terminal = yield* Queue.take(h.terminalReceipts); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type === "turn.terminal") + assert.equal(terminal.runtimeEvidence?.runtimeGeneration, "retirement-generation-2"); + yield* h.fork(replacement, "claude-replacement-generation-fork"); + yield* h.close; + assert.equal( + h.chronology.filter((entry) => entry === "abandon:retirement-generation-1").length, + 1, + ); + assert.equal( + h.chronology.filter((entry) => entry === "abandon:retirement-generation-2").length, + 1, + ); + assert.equal(h.forks[0]?.sessionId, replacement.nativeThreadRef?.nativeId); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each(["failure", "interruption"] as const)( + "withholds native fork after abandonment %s", + (mode) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.end(0); + h.setAbandonmentMode(mode === "failure" ? "failure" : "held"); + if (mode === "failure") { + const failure = yield* h + .fork(h.source, "claude-failed-retirement-target") + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterForkThreadError"); + if (failure._tag === "ProviderAdapterForkThreadError") + assert.instanceOf(failure.cause, ProviderRuntimeBindingError); + } else { + const pending = yield* h + .fork(h.source, "claude-interrupted-retirement-target") + .pipe(Effect.forkScoped); + yield* Deferred.await(h.abandonmentStarted); + yield* Fiber.interrupt(pending); + assert.isTrue(Exit.isFailure(yield* Fiber.await(pending))); + } + assert.lengthOf(h.forks, 0); + assert.lengthOf(h.queries, 1, "failed retirement must not launch a target query"); + assert.isFalse(h.chronology.includes("abandon:retirement-generation-1")); + h.setAbandonmentMode("normal"); + yield* Deferred.succeed(h.releaseAbandonment, undefined); + const unknown = yield* h.fork(h.source, "claude-no-replay-target").pipe(Effect.flip); + assert.equal(unknown._tag, "ProviderAdapterForkThreadError"); + if (unknown._tag === "ProviderAdapterForkThreadError") { + assert.instanceOf(unknown.cause, ProviderAdapterProtocolError); + if (Schema.is(ProviderAdapterProtocolError)(unknown.cause)) + assert.include(unknown.cause.detail, "unconfirmed"); + } + assert.lengthOf(h.forks, 0, "an unknown retirement must not replay native fork"); + yield* h.close; + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + describe("ClaudeAdapterV2 source-owned fork barrier", () => { + it.effect.each([ + { + title: + "lazy target session retires the naturally ended source before binding its own query", + ended: true, + }, + { + title: "lazy target session retires the live idle source before binding its own query", + ended: false, + }, + ])("$title", ({ ended }) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const target = yield* h.openSibling("target"); + yield* h.start(h.source, 1); + yield* Queue.offer( + h.queries[0]!.queue, + claudeSdkFrame({ + type: "system", + subtype: "init", + model: "native-source-model", + uuid: "source-owned-init", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* h.complete(0, 1); + const terminal = yield* Queue.take(h.terminalReceipts); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type === "turn.terminal") + assert.equal(terminal.runtimeEvidence?.runtimeGeneration, "retirement-generation-1"); + if (ended) yield* h.end(0); + assert.isFalse( + h.chronology.some((entry) => entry.startsWith("abandon:")), + "raw source exit cannot consume queued terminal evidence", + ); + const row = yield* h.fork( + h.claim(h.source), + "claude-source-owned-target", + target.runtime, + ); + assert.lengthOf(h.queries, 1, "a native fork does not eagerly activate the target query"); + assert.deepEqual(h.forks, [ + { + sessionId: WAKE_NATIVE_SESSION, + options: { dir: "/workspace", upToMessageId: "retirement-cursor-1" }, + threadId: ThreadId.make("claude-source-owned-target"), + providerSessionId: ProviderSessionId.make("claude-retirement-session-target"), + }, + ]); + assert.equal(row.nativeThreadRef?.nativeId, "retirement-fork-1"); + assert.equal(row.providerSessionId, target.runtime.providerSessionId); + assert.deepEqual( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + [{ owner: "source", method: "abandon", generation: "retirement-generation-1" }], + ); + yield* h.start(row, 2, target.runtime); + assert.equal(h.queries[1]?.input.options.resume, "retirement-fork-1"); + assert.isBelow( + h.chronology.indexOf("abandon:retirement-generation-1"), + h.chronology.indexOf(`fork:${WAKE_NATIVE_SESSION}`), + ); + assert.isBelow( + h.chronology.indexOf(`fork:${WAKE_NATIVE_SESSION}`), + h.chronology.indexOf("bind:retirement-generation-2:retirement-fork-1"), + ); + assert.isFalse( + h.lifecycleCalls.some( + (call) => call.owner === "target" && call.generation === "retirement-generation-1", + ), + ); + yield* h.close; + yield* target.close; + assert.equal( + h.lifecycleCalls.filter( + (call) => call.method === "abandon" && call.generation === "retirement-generation-1", + ).length, + 1, + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each(["source", "target"] as const)( + "rejects an active %s while the other session is idle", + (activeOwner) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const target = yield* h.openSibling("target"); + yield* h.start(h.source, 1); + if (activeOwner === "target") { + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.start(target.row, 2, target.runtime); + } + const error = yield* h + .fork(h.claim(h.source), "claude-active-owner-denied", target.runtime) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); + assert.lengthOf(h.forks, 0); + assert.equal(h.queries[0]?.closeCount, 0); + assert.lengthOf( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + 0, + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each([ + "app-thread", + "provider-thread", + "session", + "instance", + "driver", + "native-thread", + "native-driver", + "generation", + ] as const)("rejects mismatched source %s without retiring an unrelated query", (field) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const target = yield* h.openSibling("target"); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + const source = h.claim(h.source); + assert.isDefined(source.runtimeIdentity); + if (source.runtimeIdentity === undefined || source.nativeThreadRef === null) + return assert.fail("missing bound source fixture"); + const wrong: OrchestrationV2ProviderThread = + field === "app-thread" + ? { ...source, appThreadId: ThreadId.make("foreign-app") } + : field === "provider-thread" + ? { ...source, id: target.row.id } + : field === "session" + ? { ...source, providerSessionId: target.runtime.providerSessionId } + : field === "instance" + ? { ...source, providerInstanceId: ProviderInstanceId.make("foreign-instance") } + : field === "driver" + ? { ...source, driver: ProviderDriverKind.make("codex") } + : field === "native-thread" + ? { + ...source, + nativeThreadRef: { + ...source.nativeThreadRef, + nativeId: "foreign-native", + }, + } + : field === "native-driver" + ? { + ...source, + nativeThreadRef: { + ...source.nativeThreadRef, + driver: ProviderDriverKind.make("codex"), + }, + } + : { + ...source, + runtimeIdentity: { + ...source.runtimeIdentity, + runtimeGeneration: "foreign-generation", + }, + }; + const error = yield* h + .fork(wrong, "claude-wrong-source-denied", target.runtime) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); + assert.lengthOf(h.forks, 0); + assert.equal(h.queries[0]?.closeCount, 0); + assert.lengthOf( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + 0, + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect("isolates equal native IDs across two source sessions on one adapter", () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness({ sameNativeId: true }); + const other = yield* h.openSibling("other-source"); + const target = yield* h.openSibling("target"); + // Native IDs derive provider row IDs. Keep these synthetic source rows + // distinct so the fixture retains both owners of the shared native ID. + const otherSource = { + ...other.row, + id: ProviderThreadId.make("claude-retirement-other-source-row"), + }; + assert.notEqual(h.source.id, otherSource.id); + assert.notEqual(h.source.appThreadId, otherSource.appThreadId); + assert.notEqual(h.source.providerSessionId, otherSource.providerSessionId); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.start(otherSource, 2, other.runtime); + yield* h.complete(1, 2); + yield* Queue.take(h.terminalReceipts); + assert.equal(h.source.nativeThreadRef?.nativeId, otherSource.nativeThreadRef?.nativeId); + yield* h.fork(h.claim(h.source), "claude-first-source-fork", target.runtime); + assert.deepEqual( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + [{ owner: "source", method: "abandon", generation: "retirement-generation-1" }], + ); + assert.equal( + h.queries[1]?.closeCount, + 0, + "same native ID is not ownership of the other session's query", + ); + yield* h.fork(h.claim(otherSource), "claude-other-source-fork", target.runtime); + assert.deepEqual( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + [ + { owner: "source", method: "abandon", generation: "retirement-generation-1" }, + { owner: "other-source", method: "abandon", generation: "retirement-generation-2" }, + ], + ); + assert.deepEqual(h.forks[1], { + sessionId: WAKE_NATIVE_SESSION, + options: { dir: "/workspace", upToMessageId: "retirement-cursor-2" }, + threadId: ThreadId.make("claude-other-source-fork"), + providerSessionId: ProviderSessionId.make("claude-retirement-session-target"), + }); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each([ + { + title: + "an adapter replacement cannot infer retirement of another adapter's live generation", + foreignInstance: false, + }, + { + title: + "equal native IDs on different provider instances cannot retire each other's queries", + foreignInstance: true, + }, + ])("$title", ({ foreignInstance }) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness({ sameNativeId: true }); + const target = yield* h.openSibling("isolated-target", { + freshAdapter: true, + ...(foreignInstance + ? { instanceId: ProviderInstanceId.make("claude-isolated-instance") } + : {}), + }); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + assert.equal(target.row.nativeThreadRef?.nativeId, h.source.nativeThreadRef?.nativeId); + const error = yield* h + .fork(h.claim(h.source), "claude-isolated-owner-denied", target.runtime) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); + assert.lengthOf(h.forks, 0); + assert.lengthOf( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + 0, + ); + assert.equal(h.queries[0]?.closeCount, 0); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each([true, false])( + "scope-removed live generation fails closed; historical route=%s", + (historical) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const target = yield* h.openSibling("target"); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + const live = h.claim(h.source); + yield* h.close; + const abandonedBefore = h.lifecycleCalls.filter( + (call) => call.method === "abandon", + ).length; + if (historical) { + const forked = yield* h.fork( + h.source, + "claude-historical-source-fork", + target.runtime, + ); + assert.equal(forked.nativeThreadRef?.nativeId, "retirement-fork-1"); + assert.isUndefined(h.source.runtimeIdentity); + assert.lengthOf( + h.queries, + 1, + "historical fork must not fabricate a source process or eager target query", + ); + } else { + const error = yield* h + .fork(live, "claude-scope-removed-denied", target.runtime) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); + assert.lengthOf(h.forks, 0); + } + assert.equal( + h.lifecycleCalls.filter((call) => call.method === "abandon").length, + abandonedBefore, + "registry absence is not a new retirement effect", + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect( + "coalesces two target sessions and bounds the successful receipt to the source scope", + () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const one = yield* h.openSibling("target-one"); + const two = yield* h.openSibling("target-two"); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.end(0); + const source = h.claim(h.source); + h.setAbandonmentMode("held"); + const first = yield* h + .fork(source, "claude-concurrent-source-one", one.runtime) + .pipe(Effect.forkScoped); + yield* Deferred.await(h.abandonmentStarted); + const second = yield* h + .fork(source, "claude-concurrent-source-two", two.runtime) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + h.setAbandonmentMode("normal"); + yield* Deferred.succeed(h.releaseAbandonment, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + yield* h.fork(source, "claude-known-retirement-again", one.runtime); + assert.lengthOf(h.forks, 3); + assert.deepEqual( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + [{ owner: "source", method: "abandon", generation: "retirement-generation-1" }], + ); + yield* h.close; + const error = yield* h + .fork(source, "claude-retirement-receipt-expired", two.runtime) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); + assert.lengthOf(h.forks, 3); + assert.equal(h.lifecycleCalls.filter((call) => call.method === "abandon").length, 1); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect("rechecks target activity after the source retirement awaited", () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const target = yield* h.openSibling("target"); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.end(0); + const source = h.claim(h.source); + h.setAbandonmentMode("held"); + const pending = yield* h + .fork(source, "claude-target-became-active", target.runtime) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(h.abandonmentStarted); + yield* h.start(target.row, 2, target.runtime); + h.setAbandonmentMode("normal"); + yield* Deferred.succeed(h.releaseAbandonment, undefined); + assert.isTrue(Exit.isFailure(yield* Fiber.join(pending))); + assert.lengthOf(h.forks, 0); + assert.equal(h.queries[1]?.closeCount, 0); + assert.isFalse( + h.lifecycleCalls.some( + (call) => call.method === "abandon" && call.generation === "retirement-generation-2", + ), + ); + yield* h.complete(1, 2); + const terminal = yield* Queue.take(h.terminalReceipts); + assert.equal(terminal.type, "turn.terminal"); + if (terminal.type === "turn.terminal") + assert.equal(terminal.runtimeEvidence?.runtimeGeneration, "retirement-generation-2"); + yield* h.fork(source, "claude-target-idle-after-race", target.runtime); + assert.lengthOf(h.forks, 1); + assert.equal( + h.lifecycleCalls.filter( + (call) => call.method === "abandon" && call.generation === "retirement-generation-1", + ).length, + 1, + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect("withholds two separate target sessions after an unknown source abandonment", () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const one = yield* h.openSibling("target-one"); + const two = yield* h.openSibling("target-two"); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.end(0); + const source = h.claim(h.source); + h.setAbandonmentMode("held"); + const first = yield* h + .fork(source, "claude-two-targets-unknown-one", one.runtime) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(h.abandonmentStarted); + const second = yield* h + .fork(source, "claude-two-targets-unknown-two", two.runtime) + .pipe(Effect.exit, Effect.forkScoped); + yield* Effect.yieldNow; + h.setAbandonmentMode("failure"); + yield* Deferred.succeed(h.releaseAbandonment, undefined); + assert.isTrue(Exit.isFailure(yield* Fiber.join(first))); + assert.isTrue(Exit.isFailure(yield* Fiber.join(second))); + assert.lengthOf(h.forks, 0); + assert.lengthOf(h.queries, 1); + assert.deepEqual( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + [{ owner: "source", method: "abandon", generation: "retirement-generation-1" }], + ); + h.setAbandonmentMode("normal"); + yield* h.close; + assert.equal( + h.lifecycleCalls.filter((call) => call.method === "abandon").length, + 1, + "waiting targets and source scope cleanup must not replay unknown abandonment", + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each(["failure", "interruption"] as const)( + "target session cannot fork through source retirement %s", + (mode) => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness(); + const target = yield* h.openSibling("target"); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + yield* h.end(0); + const source = h.claim(h.source); + h.setAbandonmentMode(mode === "failure" ? "failure" : "held"); + if (mode === "failure") { + const error = yield* h + .fork(source, "claude-source-failure-target", target.runtime) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); + if (error._tag === "ProviderAdapterForkThreadError") + assert.instanceOf(error.cause, ProviderRuntimeBindingError); + } else { + const pending = yield* h + .fork(source, "claude-source-interruption-target", target.runtime) + .pipe(Effect.forkScoped); + yield* Deferred.await(h.abandonmentStarted); + yield* Fiber.interrupt(pending); + assert.isTrue(Exit.isFailure(yield* Fiber.await(pending))); + } + assert.lengthOf(h.forks, 0); + assert.lengthOf(h.queries, 1); + assert.isFalse( + h.lifecycleCalls.some((call) => call.owner === "target" && call.method === "abandon"), + ); + h.setAbandonmentMode("normal"); + yield* Deferred.succeed(h.releaseAbandonment, undefined); + const repeated = yield* h + .fork(source, "claude-source-unknown-not-replayed", target.runtime) + .pipe(Effect.flip); + assert.equal(repeated._tag, "ProviderAdapterForkThreadError"); + assert.equal( + h.lifecycleCalls.filter((call) => call.method === "abandon").length, + 1, + "unknown source retirement must not be retried by a different target runtime", + ); + assert.lengthOf(h.forks, 0); + yield* h.close; + assert.equal( + h.lifecycleCalls.filter((call) => call.method === "abandon").length, + 1, + "source scope cleanup must not retry an unknown abandonment", + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect( + "old source scope cleanup preserves a newer owner of the same provider-thread identity", + () => + Effect.scoped( + Effect.gen(function* () { + const h = yield* makeRetirementHarness({ sameNativeId: true }); + yield* h.start(h.source, 1); + yield* h.complete(0, 1); + yield* Queue.take(h.terminalReceipts); + const replacement = yield* h.openSibling("replacement-source", { + threadId: h.source.appThreadId!, + }); + const row = yield* replacement.runtime.resumeThread({ + providerThread: h.claim(h.source), + }); + yield* h.start(row, 2, replacement.runtime); + yield* h.complete(1, 2); + yield* Queue.take(h.terminalReceipts); + const current = h.claim(row); + assert.equal(current.id, h.source.id); + assert.equal(current.runtimeIdentity?.runtimeGeneration, "retirement-generation-2"); + yield* h.close; + assert.isFalse(h.chronology.includes("abandon:retirement-generation-2")); + const target = yield* h.openSibling("target"); + yield* h.fork(current, "claude-new-source-owner-fork", target.runtime); + assert.deepEqual( + h.lifecycleCalls.filter((call) => call.method === "abandon"), + [ + { owner: "source", method: "abandon", generation: "retirement-generation-1" }, + { + owner: "replacement-source", + method: "abandon", + generation: "retirement-generation-2", + }, + ], + ); + yield* replacement.close; + assert.equal(h.lifecycleCalls.filter((call) => call.method === "abandon").length, 2); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + }); + + it.effect.each([ + { title: "rewinds only the latest turn of a three-turn Claude thread", reset: false }, + { title: "resets a Claude thread when rewind removes every recorded turn", reset: true }, + ])("$title", ({ reset }) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-runtime-rewind-", + }); + const queues: Array> = []; + const options: Array = []; + const chronology: Array = []; + const events: Array = []; + let generations = 0; + let nativeIds = 0; + const lifecycle: ProviderRuntimeLifecycle = { + reserve: () => + Effect.sync(() => { + const generation = `query-generation-${++generations}`; + chronology.push(`reserve:${generation}`); + return generation; + }), + bind: (binding) => + Effect.sync(() => { + chronology.push(`bind:${binding.runtimeGeneration}`); + return { + ...binding.providerThread, + runtimeIdentity: { + runtimeGeneration: binding.runtimeGeneration, + evidenceRevision: 1, + requested: binding.requested, + observed: binding.observed, + }, + }; + }), + abandon: (generation) => + Effect.sync(() => { + chronology.push(`abandon:${generation}`); + }), + invalidate: () => Effect.void, + }; + const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + idAllocator, + queryRunner: { + allocateSessionId: Effect.sync(() => + ++nativeIds === 1 ? WAKE_NATIVE_SESSION : "rewind-reset-native", + ), + open: (input) => + Effect.gen(function* () { + chronology.push(`open:${options.length + 1}`); + options.push(input.options); + const queue = yield* Queue.unbounded(); + queues.push(queue); + return { + messages: Stream.fromQueue(queue), + offer: () => Effect.void, + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Effect.sync(() => { + chronology.push(`close:${queues.indexOf(queue) + 1}`); + }).pipe(Effect.andThen(Queue.shutdown(queue))), + }; + }), + forkSession: () => Effect.die("unused fork"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make(`claude-runtime-rewind-${reset}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`claude-runtime-rewind-${reset}`), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + runtimeLifecycle: lifecycle, + }); + let row = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + assert.equal( + generations, + 0, + "logical thread creation must not activate a process generation", + ); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + const start = (ordinal: number) => + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: row, + now: DateTime.makeUnsafe("2026-09-01T00:00:00Z"), + attemptId: RunAttemptId.make(`rewind-${reset}-${ordinal}`), + text: `Prompt ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + }), + ); + for (let ordinal = 1; ordinal <= 3; ordinal++) { + yield* start(ordinal); + if (ordinal === 1) + yield* Queue.offer( + queues[0]!, + claudeSdkFrame({ + type: "system", + subtype: "init", + model: "native-claude-model", + uuid: "init-original", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + queues[0]!, + makeAssistantTextFrame({ + uuid: `assistant-rewind-${ordinal}`, + text: `Answer ${ordinal}`, + }), + ); + yield* Queue.offer( + queues[0]!, + makeResultFrame({ uuid: `result-rewind-${ordinal}`, result: `Answer ${ordinal}` }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === ordinal, + `rewind turn ${ordinal}`, + ); + } + const init = events.find((event) => event.type === "runtime_identity.observed"); + assert.isDefined(init); + if (init?.type !== "runtime_identity.observed") return; + assert.equal(init.binding.runtimeGeneration, "query-generation-1"); + assert.equal(init.requested.model, CLAUDE_TEST_MODEL_SELECTION.model); + assert.deepEqual(init.observed.model, { + status: "observed", + value: "native-claude-model", + sourceEvent: "claude.system:init", + }); + assert.equal(init.observed.backend.status, "unavailable"); + assert.equal(init.observed.account.status, "unavailable"); + assert.equal(init.observed.serviceTier.status, "unavailable"); + row = { + ...row, + runtimeIdentity: { + runtimeGeneration: init.binding.runtimeGeneration, + evidenceRevision: 2, + requested: init.requested, + observed: init.observed, + }, + }; + const turns = events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed" + ? [event.providerTurn] + : [], + ); + const retained = turns.find((turn) => turn.ordinal === 2); + assert.isDefined(retained); + if (retained === undefined) return; + const rolledBack = yield* runtime.rollbackThread({ + providerThread: row, + providerThreadTurns: turns, + target: reset + ? { + type: "thread_start", + checkpointId: CheckpointId.make("rewind-all"), + appRunOrdinal: 0, + } + : { + type: "provider_turn", + checkpointId: CheckpointId.make("rewind-retained"), + appRunOrdinal: 2, + providerTurn: retained, + }, + }); + assert.equal( + options.length, + 1, + "rollback must close the old query without opening a replacement", + ); + assert.equal(generations, 1, "rollback must not manufacture a process generation"); + assert.include(chronology, "close:1"); + assert.include(chronology, "abandon:query-generation-1"); + assert.isUndefined(rolledBack.providerThread.runtimeIdentity?.runtimeGeneration); + if (reset) { + assert.equal(rolledBack.providerThread.nativeThreadRef?.nativeId, "rewind-reset-native"); + assert.isNull(rolledBack.providerThread.nativeConversationHeadRef); + } else { + assert.equal(rolledBack.providerThread.nativeThreadRef?.nativeId, WAKE_NATIVE_SESSION); + assert.equal( + rolledBack.providerThread.nativeConversationHeadRef?.nativeId, + "assistant-rewind-2", + ); + assert.deepEqual( + rolledBack.providerThread.runtimeIdentity?.observed, + unobservedRuntimeIdentity(), + ); + } + row = rolledBack.providerThread; + yield* start(reset ? 1 : 3); + assert.equal(options.length, 2); + assert.equal(generations, 2); + assert.isBelow( + chronology.indexOf("reserve:query-generation-2"), + chronology.indexOf("open:2"), + ); + assert.isBelow(chronology.indexOf("open:2"), chronology.indexOf("bind:query-generation-2")); + if (reset) assert.isUndefined(options[1]?.resumeSessionAt); + else assert.equal(options[1]?.resumeSessionAt, "assistant-rewind-2"); + const observedCount = events.filter( + (event) => event.type === "runtime_identity.observed", + ).length; + yield* Queue.offer( + queues[1]!, + claudeSdkFrame({ + type: "system", + subtype: "init", + model: "replacement-native-model", + uuid: "init-replacement", + session_id: row.nativeThreadRef!.nativeId, + }), + ); + yield* awaitUntil( + () => + events.filter((event) => event.type === "runtime_identity.observed").length > + observedCount, + "replacement init", + ); + const replacement = events + .filter((event) => event.type === "runtime_identity.observed") + .at(-1); + if (replacement?.type !== "runtime_identity.observed") + return assert.fail("missing replacement observation"); + assert.equal(replacement.binding.runtimeGeneration, "query-generation-2"); + assert.deepEqual(replacement.observed.model, { + status: "observed", + value: "replacement-native-model", + sourceEvent: "claude.system:init", + }); + assert.isFalse( + events.some( + (event) => + event.type === "runtime_identity.observed" && + event.binding.runtimeGeneration === "query-generation-1" && + event.observed.model.status === "observed" && + event.observed.model.value === "replacement-native-model", + ), + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); }); describe("ClaudeAdapterV2 query message stream", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index ba0cfbd8f..a63d76ca2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -84,6 +84,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -2727,7 +2728,35 @@ interface ActiveClaudeSubagent { lastAssistantMessageId: string | null; } +class ClaudeProducerContext extends Context.Reference( + "t3/ClaudeAdapterV2/ProducerContext", + { defaultValue: () => undefined }, +) {} + +export function claudeObservedRuntimeIdentity(model: string | undefined) { + return { + backend: { + status: "unavailable" as const, + reason: "The SDK init message does not identify the effective model backend.", + }, + model: model?.trim() + ? { status: "observed" as const, value: model.trim(), sourceEvent: "claude.system:init" } + : { status: "unavailable" as const, reason: "Claude SDK init did not report a model." }, + account: { + status: "unavailable" as const, + reason: "The SDK init message does not bind an account to this runtime.", + }, + serviceTier: { + status: "unavailable" as const, + reason: "The SDK init message does not report a service tier.", + }, + }; +} + interface ClaudeLiveQueryContext { + readonly runtimeGeneration: string; + readonly providerThread: OrchestrationV2ProviderThread; + acceptingEvidence: boolean; readonly nativeThreadId: string; readonly query: ClaudeAgentSdkQuerySession; readonly queryPolicyKey: string; @@ -2966,6 +2995,19 @@ export function makeClaudeAdapterV2( const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; + type SourceQueryRegistration = { + readonly binding: NonNullable>; + readonly verify: ( + source: OrchestrationV2ProviderThread, + ) => Effect.Effect; + readonly retire: ( + source: OrchestrationV2ProviderThread, + ) => Effect.Effect; + retired: boolean; + }; + // Lazy fork runs in the target session. Only the source's guarded closure + // may retire its producer; native IDs alone do not establish that ownership. + const sourceQueries = new Map(); // Re-scan on every send: skills are added and switched off mid-session, and // the scan is a few directory reads. A skill switched off via skillOverrides, @@ -3010,6 +3052,74 @@ export function makeClaudeAdapterV2( const interruptedTurns = yield* Ref.make(new Set()); const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); + const launchedGenerations = new Set(); + const attemptedAbandonments = new Set(); + // A stream can end before its queued terminal commits. Keep successful + // bindings until an explicit close barrier, never abandon on raw exit. + const boundQueriesByNativeThread = new Map(); + const retirementPermit = yield* Semaphore.make(1); + let queryBindingUnknown = false; + let sourceOwnerClosed = false; + let sourceRegistration: SourceQueryRegistration | undefined; + const retireGeneration = ( + generation: string, + before: Effect.Effect = Effect.void, + after: Effect.Effect = Effect.void, + requireKnownBinding = false, + ) => + retirementPermit + .withPermits(1)( + Effect.gen(function* () { + if (requireKnownBinding && queryBindingUnknown) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The previous query retirement is unconfirmed; it must not be replayed.", + }); + if (!launchedGenerations.has(generation)) return; + yield* before; + attemptedAbandonments.add(generation); + yield* input.runtimeLifecycle?.abandon(generation) ?? Effect.void; + attemptedAbandonments.delete(generation); + launchedGenerations.delete(generation); + if (sourceRegistration?.binding.runtimeGeneration === generation) + sourceRegistration.retired = true; + for (const [nativeThreadId, bound] of boundQueriesByNativeThread) { + if (bound.runtimeGeneration === generation) + boundQueriesByNativeThread.delete(nativeThreadId); + } + }).pipe( + Effect.tapError((error) => + error._tag === "ProviderAdapterProtocolError" + ? Effect.void + : Effect.sync(() => { + queryBindingUnknown = true; + }), + ), + Effect.onInterrupt(() => + Effect.sync(() => { + queryBindingUnknown = true; + }), + ), + ), + ) + .pipe( + // Stream exit acknowledgement must not block a replacement's binding. + Effect.andThen( + after.pipe( + Effect.tapError(() => + Effect.sync(() => { + queryBindingUnknown = true; + }), + ), + Effect.onInterrupt(() => + Effect.sync(() => { + queryBindingUnknown = true; + }), + ), + ), + ), + ); const openedNativeThreads = yield* Ref.make(new Set()); const latestPlanByKind = yield* Ref.make(new Map()); const planIdsByNativeItem = yield* Ref.make( @@ -3248,7 +3358,29 @@ export function makeClaudeAdapterV2( const runPromise = Effect.runPromiseWith(runtimeContext); const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); + Effect.gen(function* () { + const producer = yield* ClaudeProducerContext; + if (producer !== undefined && !producer.acceptingEvidence) return; + const binding = + producer === undefined + ? undefined + : ProviderAdapter.runtimeBinding( + producer.providerThread, + producer.runtimeGeneration, + ); + yield* Queue.offer( + events, + binding === undefined || event.type === "runtime_identity.observed" + ? event + : { + ...event, + runtimeEvidence: { + ...binding, + evidenceRevision: producer!.providerThread.runtimeIdentity?.evidenceRevision, + }, + }, + ); + }).pipe(Effect.asVoid); // Claude emits retry progress but no recovered frame; the next // assistant message is the first reliable evidence of recovery. @@ -6958,7 +7090,12 @@ export function makeClaudeAdapterV2( // the replacement open succeeds or fails below. const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; if (existing !== null) { + existing.acceptingEvidence = false; + yield* Ref.update(queryContext, (current) => + current?.query === existing.query ? null : current, + ); yield* existing.query.close.pipe(Effect.ignore); + yield* retireGeneration(existing.runtimeGeneration); if (existing.nativeThreadId !== nativeThreadId) { yield* clearWakeStateForNativeThread(existing.nativeThreadId); yield* resetBackgroundTaskStateForNativeThreadProcess(existing.nativeThreadId, { @@ -6997,6 +7134,29 @@ export function makeClaudeAdapterV2( onUserDialog, supportedDialogKinds: ["resume_return"], }); + if (queryBindingUnknown) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The previous query launch has an unconfirmed binding; it must not be replayed.", + }); + const runtimeGeneration = yield* ( + input.runtimeLifecycle?.reserve(turnInput.threadId) ?? + idAllocator.allocate + .event({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId }) + .pipe( + Effect.map(String), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Cannot reserve a Claude process generation.", + cause, + }), + ), + ) + ); + launchedGenerations.add(runtimeGeneration); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, @@ -7004,6 +7164,8 @@ export function makeClaudeAdapterV2( options: queryOptions, }) .pipe( + Effect.tapError(() => retireGeneration(runtimeGeneration)), + Effect.onInterrupt(() => retireGeneration(runtimeGeneration)), Effect.tapError(() => // Same-native-thread replacement: the old process is already // dead, so its process-scoped roster is not authoritative. @@ -7040,8 +7202,49 @@ export function makeClaudeAdapterV2( yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { status: "active", }); + const requested = ProviderAdapter.requestedRuntimeIdentity( + turnInput.modelSelection, + CLAUDE_PROVIDER, + ); + const boundThread = yield* ( + input.runtimeLifecycle === undefined + ? Effect.succeed({ + ...turnInput.providerThread, + runtimeIdentity: { + runtimeGeneration, + evidenceRevision: + (turnInput.providerThread.runtimeIdentity?.evidenceRevision ?? 0) + 1, + requested, + observed: ProviderAdapter.unobservedRuntimeIdentity(), + }, + }) + : input.runtimeLifecycle.bind({ + providerThread: turnInput.providerThread, + runtimeGeneration, + requested, + observed: ProviderAdapter.unobservedRuntimeIdentity(), + }) + ).pipe( + Effect.tapError(() => + Effect.gen(function* () { + queryBindingUnknown = true; + yield* querySession.close.pipe(Effect.ignore); + yield* retireGeneration(runtimeGeneration); + }), + ), + Effect.onInterrupt(() => + Effect.gen(function* () { + queryBindingUnknown = true; + yield* querySession.close.pipe(Effect.ignore); + yield* retireGeneration(runtimeGeneration); + }), + ), + ); const closed = yield* Deferred.make(); const context: ClaudeLiveQueryContext = { + runtimeGeneration, + providerThread: boundThread, + acceptingEvidence: true, nativeThreadId, query: querySession, queryPolicyKey, @@ -7057,18 +7260,107 @@ export function makeClaudeAdapterV2( ), ), }; + boundQueriesByNativeThread.set(nativeThreadId, context); + const binding = ProviderAdapter.runtimeBinding(boundThread, runtimeGeneration); + if (binding !== undefined) { + const previous = sourceRegistration; + if ( + previous !== undefined && + sourceQueries.get(previous.binding.providerThreadId) === previous + ) { + sourceQueries.delete(previous.binding.providerThreadId); + } + const registration: SourceQueryRegistration = { + binding, + retired: false, + verify: (source) => + Effect.gen(function* () { + if (queryBindingUnknown) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The source query binding or retirement is unconfirmed; it must not be replayed.", + }); + const claimed = ProviderAdapter.runtimeBinding( + source, + source.runtimeIdentity?.runtimeGeneration ?? binding.runtimeGeneration, + ); + if ( + sourceOwnerClosed || + sourceRegistration !== registration || + sourceQueries.get(binding.providerThreadId) !== registration || + claimed === undefined || + source.nativeThreadRef?.driver !== CLAUDE_PROVIDER || + claimed.threadId !== binding.threadId || + claimed.providerThreadId !== binding.providerThreadId || + claimed.providerSessionId !== binding.providerSessionId || + claimed.providerInstanceId !== binding.providerInstanceId || + claimed.driver !== binding.driver || + claimed.nativeThreadId !== binding.nativeThreadId || + claimed.runtimeGeneration !== binding.runtimeGeneration + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The source query ownership or generation changed; native fork must not run.", + }); + } + const sourceTurn = yield* Ref.get(activeTurn); + if (sourceTurn !== null) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Cannot fork an active source Claude turn ${sourceTurn.providerTurnId}.`, + }); + }), + retire: (source) => + Effect.gen(function* () { + yield* registration.verify(source); + yield* closeCapturedQuery(context, registration.verify(source)); + yield* registration.verify(source); + if (!registration.retired) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The source query retirement was not confirmed; native fork must not run.", + }); + }), + }; + sourceRegistration = registration; + sourceQueries.set(binding.providerThreadId, registration); + } yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => { - if ( - message.type === "system" && - (message.subtype === "init" || message.subtype === "status") && - message.permissionMode !== undefined - ) { - context.permissionMode = message.permissionMode; - } - return handleSdkMessage({ query: querySession, message }); - }), + Stream.runForEach((message) => + Effect.gen(function* () { + if ( + !context.acceptingEvidence || + (yield* Ref.get(queryContext))?.query !== querySession + ) + return; + if (message.type === "system" && message.subtype === "init") { + const reportedModel = Reflect.get(message, "model"); + const binding = ProviderAdapter.runtimeBinding(boundThread, runtimeGeneration); + if (binding !== undefined) + yield* emitProviderEvent({ + type: "runtime_identity.observed", + driver: CLAUDE_PROVIDER, + binding, + requested, + observed: claudeObservedRuntimeIdentity( + typeof reportedModel === "string" ? reportedModel : undefined, + ), + }); + } + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + yield* handleSdkMessage({ query: querySession, message }); + }).pipe(Effect.provideService(ClaudeProducerContext, context)), + ), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { @@ -7094,6 +7386,7 @@ export function makeClaudeAdapterV2( }), ), Effect.ensuring(Deferred.succeed(closed, undefined)), + Effect.provideService(ClaudeProducerContext, context), Effect.forkIn(sessionScope), ); return context; @@ -7428,31 +7721,91 @@ export function makeClaudeAdapterV2( ); }); - const closeLiveQueryForNativeThread = Effect.fnUntraced(function* (nativeThreadId: string) { - const existing = yield* Ref.get(queryContext); - if (existing === null || existing.nativeThreadId !== nativeThreadId) { - return; - } - - existing.stopping = true; - yield* existing.query.close.pipe(Effect.ignore); - const closed = yield* Deferred.await(existing.closed).pipe( - Effect.timeoutOption("10 seconds"), - ); - if (Option.isSome(closed)) { - return; - } - - yield* Effect.logWarning("orchestration-v2.claude-query-close-timeout-before-fork", { - providerSessionId: input.providerSessionId, - nativeThreadId, - }); - yield* Ref.update(queryContext, (current) => - current?.query === existing.query ? null : current, + const closeCapturedQuery = Effect.fnUntraced(function* ( + existing: ClaudeLiveQueryContext, + verifyOwner: Effect.Effect< + void, + ProviderAdapter.ProviderAdapterProtocolError + > = Effect.void, + ) { + const nativeThreadId = existing.nativeThreadId; + if (queryBindingUnknown) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The previous query binding or retirement is unconfirmed; it must not be replayed.", + }); + // Capture before close awaits: a replacement may bind on this session + // while the old close is pending, including on the same native thread. + yield* retireGeneration( + existing.runtimeGeneration, + Effect.gen(function* () { + yield* verifyOwner; + existing.stopping = true; + existing.acceptingEvidence = false; + if ((yield* Ref.get(queryContext))?.query === existing.query) { + yield* existing.query.close.pipe(Effect.ignore); + } + }), + Effect.gen(function* () { + const closed = yield* Deferred.await(existing.closed).pipe( + Effect.timeoutOption("10 seconds"), + ); + if (Option.isSome(closed)) return; + yield* Effect.logWarning("orchestration-v2.claude-query-close-timeout-before-fork", { + providerSessionId: input.providerSessionId, + nativeThreadId, + }); + yield* Ref.update(queryContext, (current) => + current?.query === existing.query ? null : current, + ); + yield* Deferred.succeed(existing.closed, undefined); + }), + true, ); - yield* Deferred.succeed(existing.closed, undefined); + if (queryBindingUnknown) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The query binding became unconfirmed during retirement; native fork must not run.", + }); }); - yield* Effect.addFinalizer(() => closeSession()); + const closeLiveQueryForNativeThread = Effect.fnUntraced(function* (nativeThreadId: string) { + const existing = boundQueriesByNativeThread.get(nativeThreadId); + if (existing !== undefined) yield* closeCapturedQuery(existing); + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + sourceOwnerClosed = true; + }).pipe( + Effect.andThen(closeSession()), + // Failed or interrupted abandonment has an unknown effect. Scope + // cleanup closes the query but must not repeat that lifecycle request. + Effect.ensuring( + Effect.suspend(() => + Effect.forEach( + [...launchedGenerations].filter( + (generation) => !attemptedAbandonments.has(generation), + ), + (generation) => retireGeneration(generation), + { concurrency: 1, discard: true }, + ), + ).pipe(Effect.orDie), + ), + Effect.ensuring( + Effect.sync(() => { + const registration = sourceRegistration; + if ( + registration !== undefined && + sourceQueries.get(registration.binding.providerThreadId) === registration + ) { + sourceQueries.delete(registration.binding.providerThreadId); + } + sourceRegistration = undefined; + }), + ), + ), + ); const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: adapterOptions.instanceId, @@ -7656,6 +8009,17 @@ export function makeClaudeAdapterV2( return { providerThread: { ...rollbackInput.providerThread, + ...(rollbackInput.providerThread.runtimeIdentity === undefined + ? {} + : { + runtimeIdentity: { + requested: rollbackInput.providerThread.runtimeIdentity.requested, + observed: ProviderAdapter.unobservedRuntimeIdentity(), + evidenceRevision: + (rollbackInput.providerThread.runtimeIdentity.evidenceRevision ?? 0) + + 1, + }, + }), providerSessionId: input.providerSessionId, nativeConversationHeadRef: resumeSessionAt === null @@ -7689,6 +8053,12 @@ export function makeClaudeAdapterV2( ), forkThread: Effect.fn("ClaudeAdapterV2.forkThread")( function* (forkInput) { + if (sourceOwnerClosed || queryBindingUnknown) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The target query owner is closed or unconfirmed; native fork must not run.", + }); const currentTurn = yield* Ref.get(activeTurn); if (currentTurn !== null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -7697,13 +8067,43 @@ export function makeClaudeAdapterV2( }); } - const sourceNativeThreadId = yield* getNativeThreadId(forkInput.sourceProviderThread); - yield* closeLiveQueryForNativeThread(sourceNativeThreadId); + const source = forkInput.sourceProviderThread; + const sourceNativeThreadId = yield* getNativeThreadId(source); + if ( + source.driver !== CLAUDE_PROVIDER || + source.providerInstanceId !== adapterOptions.instanceId || + source.nativeThreadRef?.driver !== CLAUDE_PROVIDER + ) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The native fork source does not belong to this Claude provider instance.", + }); + const registration = sourceQueries.get(source.id); + if (registration !== undefined) yield* registration.retire(source); + else if (source.runtimeIdentity?.runtimeGeneration !== undefined) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The claimed live source generation has no owned retirement barrier; native fork must not run.", + }); const upToMessageId = yield* resolveClaudeForkUpToMessageId(forkInput); const forkOptions: ForkSessionOptions = { ...(input.runtimePolicy.cwd === null ? {} : { dir: input.runtimePolicy.cwd }), ...(upToMessageId === undefined ? {} : { upToMessageId }), }; + if (registration !== undefined) yield* registration.verify(source); + else if (sourceQueries.has(source.id)) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "A source query bound while the historical fork was being prepared.", + }); + if (sourceOwnerClosed || queryBindingUnknown || (yield* Ref.get(activeTurn)) !== null) + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: + "The target Claude session became active, closed or unconfirmed while the source fork barrier awaited.", + }); const forked = yield* queryRunner.forkSession({ sessionId: sourceNativeThreadId, options: forkOptions, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 91b3a695f..599821903 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1717,7 +1717,20 @@ describe("CodexAdapterV2 post-settle continuation", () => { onEvent: (event: ProviderAdapterV2Event) => Effect.Effect = () => Effect.void, onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: (threadId: string) => Effect.Effect, - options: Pick = {}, + options: Pick & { + readonly modelSelection?: ModelSelection; + readonly replacementTranscripts?: ReadonlyArray; + readonly onOpen?: ( + input: Parameters[0], + ordinal: number, + ) => Effect.Effect; + readonly onClose?: (ordinal: number) => Effect.Effect; + readonly onNotification?: ( + method: string, + handler: (payload: unknown) => Effect.Effect, + ordinal: number, + ) => void; + } = {}, goalRequest: CodexClient.CodexAppServerClient["Service"]["raw"]["request"] = () => Effect.succeed({ goal: null }), ) => @@ -1729,43 +1742,61 @@ describe("CodexAdapterV2 post-settle continuation", () => { (config) => fileSystem.remove(config.baseDir, { recursive: true }).pipe(Effect.orDie), ); const continuationRequests: Array = []; + let openCount = 0; const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { open: (openInput) => - Layer.build(CodexReplay.layerReplay(transcript)).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterOpenSessionError({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerSessionId: openInput.providerSessionId, - cause, - }), - ), - Effect.flatMap((context) => - Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.map((client) => - withCodexReplayChildMetadata(client, transcript, readChildMetadata), - ), - Effect.map( - (client) => - ({ - ...client, - raw: { - ...client.raw, + Effect.suspend(() => { + const ordinal = openCount++; + const actualTranscript = + ordinal === 0 ? transcript : options.replacementTranscripts?.[ordinal - 1]; + if (actualTranscript === undefined) + return Effect.die("Unexpected synthetic Codex client replacement"); + return (options.onOpen?.(openInput, ordinal) ?? Effect.void).pipe( + Effect.andThen(Effect.addFinalizer(() => options.onClose?.(ordinal) ?? Effect.void)), + Effect.andThen(Layer.build(CodexReplay.layerReplay(actualTranscript))), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: openInput.providerSessionId, + cause, + }), + ), + Effect.flatMap((context) => + Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.map((client) => + withCodexReplayChildMetadata(client, actualTranscript, readChildMetadata), + ), + Effect.map( + (client) => + ({ + ...client, + handleServerNotification: (method, handler) => { + options.onNotification?.( + method, + (payload) => Reflect.apply(handler, undefined, [payload]), + ordinal, + ); + return client.handleServerNotification(method, handler); + }, + raw: { + ...client.raw, + request: (method, params) => + method === "thread/goal/get" || method === "thread/goal/set" + ? goalRequest(method, params) + : client.raw.request(method, params), + }, request: (method, params) => - method === "thread/goal/get" || method === "thread/goal/set" - ? goalRequest(method, params) - : client.raw.request(method, params), - }, - request: (method, params) => - onRequest(method, params).pipe( - Effect.andThen(client.request(method, params)), - ), - }) satisfies CodexClient.CodexAppServerClient["Service"], + onRequest(method, params).pipe( + Effect.andThen(client.request(method, params)), + ), + }) satisfies CodexClient.CodexAppServerClient["Service"], + ), + Effect.provide(context), ), - Effect.provide(context), ), - ), - ), + ); + }), }; const adapter = CodexAdapterV2.makeCodexAdapterV2({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, @@ -1775,7 +1806,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { fileSystem, idAllocator, serverConfig, - ...options, + ...(options.resolveRuntime === undefined ? {} : { resolveRuntime: options.resolveRuntime }), continuationRequests: { offer: (request) => Effect.sync(() => { @@ -1787,12 +1818,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), - modelSelection: CODEX_TEST_MODEL_SELECTION, + modelSelection: options.modelSelection ?? CODEX_TEST_MODEL_SELECTION, runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); const providerThread = yield* runtime.ensureThread({ threadId, - modelSelection: CODEX_TEST_MODEL_SELECTION, + modelSelection: options.modelSelection ?? CODEX_TEST_MODEL_SELECTION, runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); const events: Array = []; @@ -3442,7 +3473,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { { resolveRuntime: Effect.suspend(() => { checks++; - if (checks > 1 && outcome === "failed") + if (checks > 2 && outcome === "failed") return Effect.fail( new ProviderSetupError({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, @@ -3450,13 +3481,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { detail: "Synthetic revision check failed", }), ); - if (checks > 1 && outcome === "defect") + if (checks > 2 && outcome === "defect") return Effect.die("Synthetic managed revision defect"); - if (checks > 1 && outcome === "timeout") return Effect.never; + if (checks > 2 && outcome === "timeout") return Effect.never; return Effect.succeed({ config: DEFAULT_CODEX_SETTINGS, environment: {}, - revision: checks > 1 && outcome === "changed" ? "changed" : "original", + revision: checks > 2 && outcome === "changed" ? "changed" : "original", }); }), }, @@ -3471,16 +3502,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ); yield* awaitCapacityDelay(harness); - assert.equal(checks, 1); + assert.equal(checks, 2); assert.isTrue(yield* harness.hasPendingBackgroundWork); assert.isTrue( yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), ); yield* TestClock.adjust("9 seconds"); - assert.equal(checks, 1); + assert.equal(checks, 2); assert.equal(sends, 1); yield* TestClock.adjust("1 second"); - yield* awaitUntil(() => checks === 2, "bounded managed revision check"); + yield* awaitUntil(() => checks === 3, "bounded managed revision check"); if (outcome === "timeout") { yield* TestClock.adjust("29 seconds"); assert.lengthOf(harness.terminalEvents(), 0); @@ -3505,7 +3536,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { outcome === "same" ? ["managed-original", "managed-retry"] : ["managed-original"], ); yield* TestClock.adjust("1 minute"); - assert.equal(checks, 2); + assert.equal(checks, 3); assert.equal(sends, outcome === "same" ? 2 : 1); assert.isFalse(yield* harness.hasPendingBackgroundWork); }), @@ -9245,6 +9276,844 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + it.effect.each([ + { operation: "start", tier: undefined }, + { operation: "start", tier: null }, + { operation: "start", tier: "priority" }, + { operation: "resume", tier: undefined }, + { operation: "resume", tier: null }, + { operation: "resume", tier: "priority" }, + ] as const)( + "records requested and native identity separately for Codex $operation tier $tier", + ({ operation, tier }) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = `identity-${operation}-${String(tier)}`; + const selection: ModelSelection = { + ...CODEX_TEST_MODEL_SELECTION, + model: "requested-model", + ...(tier == null ? {} : { options: [{ id: "serviceTier", value: tier }] }), + }; + const observedResult = { + ...codexReplayThreadResult({ nativeThreadId, forkedFromId: null }), + model: "native-model", + ...(tier === undefined ? { serviceTier: null } : { serviceTier: tier }), + }; + const entries = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused", + prompt: "unused", + }) + .slice(0, 5) + .map((entry) => + entry.type === "emit_inbound" && + Predicate.isObject(entry.frame) && + entry.frame.id === 2 + ? { ...entry, frame: { ...entry.frame, result: observedResult } } + : entry, + ) as Array; + if (operation === "resume") + entries.push( + { + type: "expect_outbound", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + model: selection.model, + cwd: "/workspace", + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { type: "emit_inbound", frame: { id: 3, result: observedResult } }, + ); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: nativeThreadId, entries }), + undefined, + undefined, + undefined, + { modelSelection: selection }, + ); + const row = + operation === "start" + ? harness.providerThread + : yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: selection, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + const identity = row.runtimeIdentity; + assert.isString(identity?.runtimeGeneration); + assert.equal(identity?.requested.model, "requested-model"); + assert.equal(identity?.requested.serviceTier, tier ?? null); + assert.deepEqual(identity?.observed.model, { + status: "observed", + value: "native-model", + sourceEvent: "codex.thread/open", + }); + assert.deepEqual(identity?.observed.backend, { + status: "observed", + value: "openai", + sourceEvent: "codex.thread/open", + }); + assert.equal(identity?.observed.account.status, "unavailable"); + if (tier === "priority") + assert.deepEqual(identity?.observed.serviceTier, { + status: "observed", + value: "priority", + sourceEvent: "codex.thread/open", + }); + else assert.equal(identity?.observed.serviceTier.status, "unavailable"); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect("buffers a native reroute until its thread-open generation is bound", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "identity-buffered-reroute"; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 5); + const entries = [ + ...preamble.slice(0, 4), + { + type: "emit_inbound" as const, + frame: { + method: "model/rerouted", + params: { + threadId: nativeThreadId, + fromModel: "gpt-5.4", + toModel: "rerouted-before-open", + turnId: "pending-native-turn", + reason: "highRiskCyberActivity", + }, + }, + }, + ...preamble.slice(4), + ]; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: nativeThreadId, entries }), + ); + yield* awaitUntil( + () => + harness.events.filter((event) => event.type === "runtime_identity.observed").length === + 2, + "bound buffered reroute", + ); + const observations = harness.events.flatMap((event) => + event.type === "runtime_identity.observed" ? [event] : [], + ); + assert.deepEqual( + observations.map((event) => event.observed.model), + [ + { status: "observed", value: "gpt-5.4", sourceEvent: "codex.thread/open" }, + { status: "observed", value: "rerouted-before-open", sourceEvent: "model/rerouted" }, + ], + ); + assert.equal( + observations[0]?.binding.runtimeGeneration, + observations[1]?.binding.runtimeGeneration, + ); + assert.equal(observations[0]?.binding.nativeThreadId, nativeThreadId); + assert.equal(observations[1]?.binding.providerThreadId, harness.providerThread.id); + assert.equal(observations[1]?.requested.model, CODEX_TEST_MODEL_SELECTION.model); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect( + "replaces a managed Codex process before the next prompt and resumes its native cursor with the current request", + () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "managed-rotation-cursor"; + let revision = "first-runtime"; + const lifecycle: Array = []; + const opens: Array< + Parameters[0] + > = []; + const sends: Array = []; + const oldReroutes: Array<(payload: unknown) => Effect.Effect> = []; + const first = makeCodexReplayTranscript({ + scenario: "identity-managed-original", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "first-turn", + prompt: "Original prompt", + }), + capacityCompletionEntry(nativeThreadId, "first-turn", "completed"), + ], + }); + const replacementTurn = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "second-turn", + prompt: "Next prompt", + }) + .slice(5) + .map((entry) => { + if ( + entry.type !== "expect_outbound" || + !Predicate.isObject(entry.frame) || + entry.frame.method !== "turn/start" + ) + return entry; + return { + ...entry, + frame: { + ...entry.frame, + params: { + ...(entry.frame.params as Record), + model: "current-request", + }, + }, + }; + }) as Array; + const replacement = makeCodexReplayTranscript({ + scenario: "identity-managed-replacement", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 3), + { + type: "expect_outbound", + frame: { + id: 2, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + model: "current-request", + cwd: "/workspace", + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + frame: { + id: 2, + result: { + ...codexReplayThreadResult({ nativeThreadId, forkedFromId: null }), + model: "replacement-native", + modelProvider: "replacement-backend", + serviceTier: "priority", + }, + }, + }, + ...replacementTurn, + capacityCompletionEntry(nativeThreadId, "second-turn", "completed"), + ], + }); + const harness = yield* makeCodexReplayHarness( + first, + undefined, + (method, params) => + Effect.sync(() => { + if (method === "turn/start") sends.push(params); + }), + undefined, + { + replacementTranscripts: [replacement], + resolveRuntime: Effect.sync(() => ({ + config: DEFAULT_CODEX_SETTINGS, + environment: { SYNTHETIC_RUNTIME_VERSION: revision }, + revision, + })), + onOpen: (input, ordinal) => + Effect.sync(() => { + opens.push(input); + lifecycle.push(`open:${ordinal}`); + }), + onClose: (ordinal) => + Effect.sync(() => { + lifecycle.push(`close:${ordinal}`); + }), + onNotification: (method, handler, ordinal) => { + if (method === "model/rerouted" && ordinal === 0) oldReroutes.push(handler); + }, + }, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("managed-original"), + text: "Original prompt", + }), + ); + yield* harness.firstTerminal; + revision = "replacement-runtime"; + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("managed-next"), + text: "Next prompt", + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + model: "current-request", + options: [{ id: "serviceTier", value: "priority" }], + }, + }); + yield* awaitUntil( + () => harness.terminalEvents().length === 2, + "replacement native turn completion", + ); + assert.deepEqual(lifecycle.slice(0, 3), ["open:0", "close:0", "open:1"]); + assert.equal(opens.length, 2); + assert.equal(opens[1]?.environment.SYNTHETIC_RUNTIME_VERSION, "replacement-runtime"); + const beforeStale = harness.events.filter( + (event) => event.type === "runtime_identity.observed", + ).length; + assert.lengthOf(oldReroutes, 1); + yield* oldReroutes[0]!({ + threadId: nativeThreadId, + fromModel: "native-old", + toModel: "stale-native-model", + turnId: "retired-native-turn", + reason: "highRiskCyberActivity", + }); + yield* Effect.yieldNow; + assert.equal( + harness.events.filter((event) => event.type === "runtime_identity.observed").length, + beforeStale, + "a retired producer cannot borrow the replacement's generation", + ); + const observations = harness.events.filter( + (event) => event.type === "runtime_identity.observed", + ); + const last = observations.at(-1); + assert.isDefined(last); + if (last?.type !== "runtime_identity.observed") return; + assert.notEqual( + last.binding.runtimeGeneration, + harness.providerThread.runtimeIdentity?.runtimeGeneration, + ); + assert.equal(last.binding.nativeThreadId, nativeThreadId); + assert.equal(last.requested.model, "current-request"); + assert.deepEqual(last.observed.model, { + status: "observed", + value: "replacement-native", + sourceEvent: "codex.thread/open", + }); + assert.equal(sends.length, 2); + assert.deepEqual( + sends.map((value) => (Predicate.isObject(value) ? value.input : undefined)), + [[{ type: "text", text: "Original prompt" }], [{ type: "text", text: "Next prompt" }]], + "the original prompt is never resent", + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each(["failure", "interruption"] as const)( + "never reuses a closed Codex producer after managed replacement %s", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = `identity-rotation-${outcome}`; + let revision = "original"; + let opens = 0; + let sends = 0; + const closed: Array = []; + const candidateStarted = yield* Deferred.make(); + const original = makeCodexReplayTranscript({ + scenario: nativeThreadId, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "completed-original", + prompt: "Original only", + }), + capacityCompletionEntry(nativeThreadId, "completed-original", "completed"), + ], + }); + const harness = yield* makeCodexReplayHarness( + original, + undefined, + (method) => + Effect.sync(() => { + if (method === "turn/start") sends++; + }), + undefined, + { + replacementTranscripts: [original], + resolveRuntime: Effect.sync(() => ({ + config: DEFAULT_CODEX_SETTINGS, + environment: {}, + revision, + })), + onOpen: (input, ordinal) => + Effect.gen(function* () { + opens++; + if (ordinal === 0) return; + yield* Deferred.succeed(candidateStarted, undefined); + if (outcome === "interruption") return yield* Effect.never; + return yield* new ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: input.providerSessionId, + cause: "synthetic replacement launch failure", + }); + }), + onClose: (ordinal) => + Effect.sync(() => { + closed.push(ordinal); + }), + }, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("identity-rotation-original"), + text: "Original only", + }), + ); + yield* harness.firstTerminal; + revision = "replacement"; + const input = makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`identity-rotation-${outcome}`), + text: "Never replay original", + }); + const replacement = yield* harness.runtime + .startTurn(input) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(candidateStarted); + if (outcome === "interruption") { + yield* Fiber.interrupt(replacement); + } else { + const result = yield* Fiber.join(replacement); + assert.equal(result._tag, "Failure"); + } + assert.include(closed, 0); + assert.equal(opens, 2); + assert.equal(sends, 1); + const retry = yield* harness.runtime.startTurn(input).pipe(Effect.result); + assert.equal(retry._tag, "Failure"); + assert.equal(opens, 2); + assert.equal(sends, 1); + assert.isFalse( + harness.events.some( + (event) => + event.type === "runtime_identity.observed" && + event.binding.runtimeGeneration !== + harness.providerThread.runtimeIdentity?.runtimeGeneration, + ), + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each(["active", "background"] as const)( + "does not rotate a shared Codex process while sibling %s work remains", + (work) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "managed-shared-busy"; + let revision = "original"; + let opens = 0; + let sends = 0; + const entries = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "busy-turn", + prompt: "Keep working", + }); + if (work === "background") + entries.push( + { + type: "emit_inbound", + frame: { + method: "item/started", + params: { + threadId: nativeThreadId, + turnId: "busy-turn", + startedAtMs: 1782622445000, + item: backgroundCommandItem("inProgress"), + }, + }, + }, + capacityCompletionEntry(nativeThreadId, "busy-turn", "completed"), + ); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: nativeThreadId, entries }), + undefined, + (method) => + Effect.sync(() => { + if (method === "turn/start") sends++; + }), + undefined, + { + resolveRuntime: Effect.sync(() => ({ + config: DEFAULT_CODEX_SETTINGS, + environment: {}, + revision, + })), + onOpen: () => + Effect.sync(() => { + opens++; + }), + }, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("managed-busy"), + text: "Keep working", + }), + ); + if (work === "background") { + yield* harness.firstTerminal; + assert.isTrue(yield* harness.hasPendingBackgroundWork); + } + revision = "replacement"; + const sibling = { + ...harness.providerThread, + id: ProviderThreadId.make("sibling-provider-row"), + appThreadId: ThreadId.make("sibling-app"), + nativeThreadRef: { + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeId: "sibling-native", + strength: "strong" as const, + }, + }; + const rejected = yield* harness.runtime + .startTurn( + makeCodexTestTurnInput({ + threadId: sibling.appThreadId, + providerThread: sibling, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("managed-sibling"), + text: "Do not replace busy process", + }), + ) + .pipe(Effect.result); + assert.equal(rejected._tag, "Failure"); + assert.equal(opens, 1); + assert.equal(sends, 1); + assert.lengthOf(harness.terminalEvents(), work === "background" ? 1 : 0); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + // A fork's first turn runs on its run's own provider-thread row, which the + // fork call never sees; the turn must bind the fork's native thread to it. + const forkFirstTurnTranscript = (scenario: string) => { + const nativeThreadId = `${scenario}-source`; + const forkThreadId = `${scenario}-fork`; + const source = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused", + prompt: "unused", + }); + const forkTurn = codexReplayPreamble({ + nativeThreadId: forkThreadId, + nativeTurnId: `${scenario}-fork-turn`, + prompt: "Continue in the fork", + }) + .slice(5) + .map((entry) => { + const frame = "frame" in entry ? (entry.frame as Record) : undefined; + return frame?.id === 3 ? { ...entry, frame: { ...frame, id: 4 } } : entry; + }) as Array; + return { + forkThreadId, + transcript: makeCodexReplayTranscript({ + scenario, + entries: [ + ...source.slice(0, 5), + { + type: "expect_outbound", + label: "thread/fork", + frame: { + id: 3, + method: "thread/fork", + params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, + }, + }, + { + type: "emit_inbound", + label: "thread/fork", + frame: { + id: 3, + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: nativeThreadId, + }), + }, + }, + ...forkTurn, + ], + }), + }; + }; + + it.effect.each([ + { + foreignFirst: false, + title: + "binds a native fork to its target run's provider-thread row so the fork's first turn starts", + }, + { + foreignFirst: true, + title: + "refuses a native fork's binding to another app thread and keeps it for the fork's own first turn", + }, + ])("$title", ({ foreignFirst }) => + Effect.gen(function* () { + const { forkThreadId, transcript } = forkFirstTurnTranscript( + foreignFirst ? "codex-fork-foreign-turn" : "codex-fork-first-turn", + ); + const requests: Array = []; + const harness = yield* makeCodexReplayHarness(transcript, undefined, (method) => + Effect.sync(() => { + requests.push(method); + }), + ); + const now = yield* DateTime.now; + const targetThreadId = ThreadId.make(`thread-${transcript.scenario}-target`); + const forked = yield* harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + targetThreadId, + }); + assert.equal(forked.nativeThreadRef?.nativeId, forkThreadId); + // The orchestrator keeps the run's prepared row id for the fork. + const row: OrchestrationV2ProviderThread = { + ...forked, + id: ProviderThreadId.make(`provider-thread:${transcript.scenario}:run-row`), + appThreadId: targetThreadId, + }; + if (foreignFirst) { + const foreignThreadId = ThreadId.make(`thread-${transcript.scenario}-foreign`); + const foreign = yield* harness.runtime + .startTurn( + makeCodexTestTurnInput({ + threadId: foreignThreadId, + providerThread: { ...row, appThreadId: foreignThreadId }, + now, + attemptId: RunAttemptId.make(`attempt-${transcript.scenario}-foreign`), + text: "Continue in the fork", + }), + ) + .pipe(Effect.result); + assert.equal(foreign._tag, "Failure"); + assert.notInclude(requests, "turn/start"); + } + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: targetThreadId, + providerThread: row, + now, + attemptId: RunAttemptId.make(`attempt-${transcript.scenario}`), + text: "Continue in the fork", + }), + ); + assert.deepEqual(requests, ["initialize", "thread/start", "thread/fork", "turn/start"]); + const bound = harness.events.filter( + (event) => + event.type === "runtime_identity.observed" && event.binding.threadId === targetThreadId, + ); + assert.equal(bound.length, 1); + if (bound[0]?.type !== "runtime_identity.observed") return; + assert.equal(bound[0].binding.providerThreadId, row.id); + assert.equal(bound[0].binding.nativeThreadId, forkThreadId); + // The source thread keeps its own binding. + assert.isTrue( + harness.events.some( + (event) => + event.type === "runtime_identity.observed" && + event.binding.providerThreadId === harness.providerThread.id, + ), + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect("rejects a replaced native fork issuer before its first turn without reforking", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = forkFirstTurnTranscript("identity-fork-replaced-issuer"); + let revision = "original"; + const requests: Array = []; + const sourceNativeId = "identity-fork-replaced-issuer-source"; + const replacement = makeCodexReplayTranscript({ + scenario: "identity-fork-replacement", + entries: [ + ...codexReplayPreamble({ + nativeThreadId: sourceNativeId, + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 3), + { + type: "expect_outbound", + frame: { + id: 2, + method: "thread/resume", + params: { + threadId: sourceNativeId, + excludeTurns: true, + model: CODEX_TEST_MODEL_SELECTION.model, + cwd: "/workspace", + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + frame: { + id: 2, + result: codexReplayThreadResult({ + nativeThreadId: sourceNativeId, + forkedFromId: null, + }), + }, + }, + ...codexReplayPreamble({ + nativeThreadId: sourceNativeId, + nativeTurnId: "replacement-source-turn", + prompt: "After replacement", + }).slice(5), + capacityCompletionEntry(sourceNativeId, "replacement-source-turn", "completed"), + ], + }); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: fixture.transcript.scenario, + entries: fixture.transcript.entries.slice(0, 7), + }), + undefined, + (method) => + Effect.sync(() => { + requests.push(method); + }), + undefined, + { + replacementTranscripts: [replacement], + resolveRuntime: Effect.sync(() => ({ + config: DEFAULT_CODEX_SETTINGS, + environment: {}, + revision, + })), + }, + ); + const targetThreadId = ThreadId.make("identity-fork-target"); + const fork = yield* harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + targetThreadId, + }); + revision = "replaced"; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("identity-fork-replacement-source"), + text: "After replacement", + }), + ); + yield* harness.firstTerminal; + const beforeForkTurn = requests.filter((method) => method === "turn/start").length; + const failed = yield* harness.runtime + .startTurn( + makeCodexTestTurnInput({ + threadId: targetThreadId, + providerThread: { ...fork, id: ProviderThreadId.make("identity-fork-prepared-row") }, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("identity-fork-replaced"), + text: "Continue in the fork", + }), + ) + .pipe(Effect.result); + assert.equal(failed._tag, "Failure"); + assert.equal(requests.filter((method) => method === "turn/start").length, beforeForkTurn); + assert.equal(requests.filter((method) => method === "thread/fork").length, 1); + assert.isFalse( + harness.events.some( + (event) => + event.type === "runtime_identity.observed" && + event.binding.threadId === targetThreadId, + ), + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each(["same-source", "blank"] as const)( + "refuses a native fork response without a new conversation: %s", + (variant) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = `identity-fork-invalid-${variant}`; + let forks = 0; + const transcript = makeCodexReplayTranscript({ + scenario: nativeThreadId, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 5), + { + type: "expect_outbound", + frame: { + id: 3, + method: "thread/fork", + params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, + }, + }, + { + type: "emit_inbound", + frame: { + id: 3, + result: codexReplayThreadResult({ + nativeThreadId: variant === "blank" ? " " : nativeThreadId, + forkedFromId: nativeThreadId, + }), + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript, undefined, (method) => + Effect.sync(() => { + if (method === "thread/fork") forks++; + }), + ); + const input = { + sourceProviderThread: harness.providerThread, + targetThreadId: ThreadId.make("invalid-fork-target"), + }; + const failed = yield* harness.runtime.forkThread(input).pipe(Effect.result); + assert.equal(failed._tag, "Failure"); + const repeated = yield* harness.runtime.forkThread(input).pipe(Effect.result); + assert.equal(repeated._tag, "Failure"); + assert.equal(forks, 1, "an unconfirmed native effect must not be blindly reforked"); + assert.isFalse( + harness.events.some( + (event) => + event.type === "runtime_identity.observed" && + event.binding.threadId === input.targetThreadId, + ), + ); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + it.effect( "falls back to fork-local thread/revert on paginated history when the source turn lacks a native reference", () => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4f6e57fd4..2b32dfcf5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -77,6 +77,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -143,6 +144,11 @@ import { ProviderAdapterInterruptError, ProviderAdapterOpenSessionError, ProviderAdapterProtocolError, + ProviderRuntimeBindingError, + requestedRuntimeIdentity, + unobservedRuntimeIdentity, + identityForRequest, + runtimeBinding, ProviderAdapterReadThreadSnapshotError, ProviderAdapterResumeThreadError, ProviderAdapterRollbackThreadError, @@ -1238,7 +1244,12 @@ export function codexThreadRuntimeParams(input: { } const decodeCodexResumeMetadata = Schema.decodeUnknownEffect( - Schema.Struct({ thread: Schema.Struct({ id: Schema.String, updatedAt: Schema.Number }) }), + Schema.Struct({ + thread: Schema.Struct({ id: Schema.String, updatedAt: Schema.Number }), + model: Schema.optional(Schema.NullOr(Schema.String)), + modelProvider: Schema.optional(Schema.NullOr(Schema.String)), + serviceTier: Schema.optional(Schema.NullOr(Schema.String)), + }), ); const decodeCodexChildModel = Schema.decodeUnknownEffect( @@ -1586,6 +1597,44 @@ class CodexInterruptAcknowledgementTimeout extends Schema.TaggedError; + readonly pendingReroutes: Map>; + active: boolean; +} + +class CodexProducerContext extends Context.Reference( + "t3/CodexAdapterV2/ProducerContext", + { defaultValue: () => undefined }, +) {} + +export function codexObservedRuntimeIdentity(response: { + readonly model?: string | null | undefined; + readonly modelProvider?: string | null | undefined; + readonly serviceTier?: string | null | undefined; +}) { + const reported = (value: string | null | undefined, dimension: string) => + value?.trim() + ? { status: "observed" as const, value: value.trim(), sourceEvent: "codex.thread/open" } + : { + status: "unavailable" as const, + reason: `The thread-open response did not report ${dimension}.`, + }; + return { + backend: reported(response.modelProvider, "a backend"), + model: reported(response.model, "a model"), + account: { + status: "unavailable" as const, + reason: "The thread-open response does not bind an account to this runtime.", + }, + serviceTier: reported(response.serviceTier, "a service tier"), + }; +} + export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): ProviderAdapterV2Shape { const { clientFactory, fileSystem, idAllocator, serverConfig } = adapterOptions; const continuationRequests = adapterOptions.continuationRequests; @@ -1598,27 +1647,117 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi openSession: (input) => Effect.gen(function* () { const scope = yield* Scope.Scope; - const resolvedRuntime = - adapterOptions.resolveRuntime === undefined - ? undefined - : yield* adapterOptions.resolveRuntime.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterOpenSessionError({ - driver: CODEX_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), + const reserveGeneration = (threadId: ThreadId) => + input.runtimeLifecycle?.reserve(threadId) ?? + idAllocator.allocate.event({ threadId, providerSessionId: input.providerSessionId }).pipe( + Effect.map(String), + Effect.mapError((cause) => + toProtocolError("Cannot reserve a Codex process generation.", cause), + ), + ); + const openProducer = (threadId: ThreadId, runtimePolicy: ProviderAdapterV2RuntimePolicy) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const generation = yield* reserveGeneration(threadId); + const producerScope = yield* Scope.make(); + const opened = yield* restore( + Effect.gen(function* () { + const resolvedRuntime = + adapterOptions.resolveRuntime === undefined + ? undefined + : yield* adapterOptions.resolveRuntime; + const actualClient = yield* clientFactory.open({ + instanceId: adapterOptions.instanceId, + threadId, + providerSessionId: input.providerSessionId, + runtimePolicy, + settings: resolvedRuntime?.config ?? adapterOptions.settings, + environment: resolvedRuntime?.environment ?? adapterOptions.environment, + }); + return { + generation, + client: actualClient, + scope: producerScope, + resolvedRuntime, + bindings: new Map(), + pendingReroutes: new Map>(), + active: true, + } satisfies CodexRuntimeProducer; + }).pipe(Effect.provideService(Scope.Scope, producerScope)), + ).pipe(Effect.exit); + if (opened._tag === "Failure") { + yield* Scope.close(producerScope, Exit.void); + yield* input.runtimeLifecycle?.abandon(generation) ?? Effect.void; + return yield* Effect.failCause(opened.cause); + } + return opened.value; + }), + ); + let currentProducer: CodexRuntimeProducer = yield* openProducer( + input.threadId, + input.runtimePolicy, + ); + const registrations: Array<(producer: CodexRuntimeProducer) => Effect.Effect> = []; + const handleServerNotification: CodexRuntimeProducer["client"]["handleServerNotification"] = + (method, handler) => { + const install = (producer: CodexRuntimeProducer) => + producer.client.handleServerNotification(method, (payload) => + Effect.suspend(() => + producer.active && producer === currentProducer + ? handler(payload).pipe(Effect.provideService(CodexProducerContext, producer)) + : Effect.void, ), ); - const client = yield* clientFactory.open({ - instanceId: adapterOptions.instanceId, - threadId: input.threadId, - providerSessionId: input.providerSessionId, - runtimePolicy: input.runtimePolicy, - settings: resolvedRuntime?.config ?? adapterOptions.settings, - environment: resolvedRuntime?.environment ?? adapterOptions.environment, + registrations.push(install); + return install(currentProducer); + }; + const handleServerRequest: CodexRuntimeProducer["client"]["handleServerRequest"] = ( + method, + handler, + ) => { + const install = (producer: CodexRuntimeProducer) => + producer.client.handleServerRequest(method, (payload) => + Effect.suspend(() => + producer.active && producer === currentProducer + ? handler(payload).pipe(Effect.provideService(CodexProducerContext, producer)) + : Effect.fail( + CodexErrors.CodexAppServerRequestError.invalidRequest( + "The Codex request belongs to a retired runtime.", + ), + ), + ), + ); + registrations.push(install); + return install(currentProducer); + }; + const client = new Proxy(currentProducer.client, { + get(_target, property) { + if (property === "raw") + return new Proxy(currentProducer.client.raw, { + get(_raw, key) { + const raw = currentProducer.client.raw; + const member = Reflect.get(raw, key); + return typeof member === "function" + ? (...args: unknown[]) => Reflect.apply(member, raw, args) + : member; + }, + }); + if (property === "handleServerNotification") return handleServerNotification; + if (property === "handleServerRequest") return handleServerRequest; + const actual = currentProducer.client; + const member = Reflect.get(actual, property); + return typeof member === "function" + ? (...args: unknown[]) => Reflect.apply(member, actual, args) + : member; + }, }); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + currentProducer.active = false; + yield* Scope.close(currentProducer.scope, Exit.void); + yield* input.runtimeLifecycle?.abandon(currentProducer.generation) ?? Effect.void; + }).pipe(Effect.orDie), + ); const additionalContextByThread = yield* Ref.make( new Map< string, @@ -1738,8 +1877,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi request.state = reduction.state; return reduction.actions; }; - // This client cannot be replaced inside its session scope. The session ID - // binds this local continuation; it is not an observed native runtime identity. + // Retries belong to the exact producer that received the original prompt. + // A replacement may resume the cursor, but cannot replay this attempt. const currentCapacityBinding = (request: CapacityRequest) => !capacityScopeClosed && capacityByThread.get(request.state.binding.nativeThreadId) === request && @@ -1751,7 +1890,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi request.state.binding.nativeThreadId && request.input.runId === request.state.binding.runId && request.input.attemptId === request.state.binding.attemptId && - request.state.binding.runtimeGeneration === input.providerSessionId; + request.state.binding.runtimeGeneration === currentProducer.generation; const releaseCapacityRequest = (request: CapacityRequest) => { if (capacityByThread.get(request.state.binding.nativeThreadId) === request) { @@ -1802,8 +1941,128 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi // path. Serialize the race so only one can publish terminal events. const turnTerminalizationPermit = yield* Semaphore.make(1); + const bindRuntimeThread = ( + producer: CodexRuntimeProducer, + thread: OrchestrationV2ProviderThread, + selection: ModelSelection, + observed: ReturnType, + ) => + Effect.gen(function* () { + if (!producer.active || producer !== currentProducer) + return yield* toProtocolError( + "The Codex issuer was replaced before its native thread could bind.", + ); + yield* getNativeThreadId(thread); + const requested = requestedRuntimeIdentity(selection, CODEX_PROVIDER); + const bound = + input.runtimeLifecycle === undefined + ? { + ...thread, + runtimeIdentity: { + runtimeGeneration: producer.generation, + evidenceRevision: (thread.runtimeIdentity?.evidenceRevision ?? 0) + 1, + requested, + observed, + }, + } + : yield* input.runtimeLifecycle.bind({ + providerThread: thread, + runtimeGeneration: producer.generation, + requested, + observed, + }); + if (!producer.active || producer !== currentProducer) + return yield* toProtocolError("The Codex native binding changed during publication."); + const nativeId = yield* getNativeThreadId(bound); + producer.bindings.set(nativeId, bound); + const reroutes = producer.pendingReroutes.get(nativeId) ?? []; + producer.pendingReroutes.delete(nativeId); + const binding = runtimeBinding(bound, producer.generation); + if (binding !== undefined && input.runtimeLifecycle === undefined) + yield* Queue.offer(events, { + type: "runtime_identity.observed", + driver: CODEX_PROVIDER, + binding, + requested, + observed, + }); + if (binding !== undefined) + for (const model of reroutes) + yield* Queue.offer(events, { + type: "runtime_identity.observed", + driver: CODEX_PROVIDER, + binding, + requested, + observed: { + ...observed, + model: { status: "observed", value: model, sourceEvent: "model/rerouted" }, + }, + }); + return bound; + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + nativeStartUnknown = true; + }), + ), + Effect.onInterrupt(() => + Effect.sync(() => { + nativeStartUnknown = true; + }), + ), + Effect.mapError( + (cause) => + new ProviderRuntimeBindingError({ + driver: CODEX_PROVIDER, + detail: + "The native thread opened but its runtime binding is unconfirmed; do not replay it.", + cause, + }), + ), + ); + const pendingForkBindings = new Map< + string, + { + readonly producer: CodexRuntimeProducer; + readonly targetThreadId: ThreadId; + readonly selection: ModelSelection; + readonly observed: ReturnType; + } + >(); const emitProviderEvent = (event: ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); + Effect.gen(function* () { + const producer = yield* CodexProducerContext; + if (producer !== undefined && (!producer.active || producer !== currentProducer)) + return; + const providerThreadId = + event.type === "provider_thread.updated" + ? event.providerThread.id + : event.type === "provider_turn.updated" + ? event.providerTurn.providerThreadId + : event.type === "turn.terminal" + ? event.providerThreadId + : undefined; + const thread = + producer === undefined || providerThreadId === undefined + ? undefined + : [...producer.bindings.values()].find((bound) => bound.id === providerThreadId); + const binding = + thread === undefined || producer === undefined + ? undefined + : runtimeBinding(thread, producer.generation); + yield* Queue.offer( + events, + binding === undefined || event.type === "runtime_identity.observed" + ? event + : { + ...event, + runtimeEvidence: { + ...binding, + evidenceRevision: thread!.runtimeIdentity?.evidenceRevision, + }, + }, + ); + }).pipe(Effect.asVoid); // Call only for new model-output activity. A local item/completed can // arrive while the upstream response stream is still retrying. @@ -3974,7 +4233,40 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi updateSubagentModel(payload.threadId, payload.threadSettings.model), ); yield* client.handleServerNotification("model/rerouted", (payload) => - updateSubagentModel(payload.threadId, payload.toModel), + Effect.gen(function* () { + yield* updateSubagentModel(payload.threadId, payload.toModel); + const producer = yield* CodexProducerContext; + const thread = producer?.bindings.get(payload.threadId); + const binding = + producer === undefined || thread === undefined + ? undefined + : runtimeBinding(thread, producer.generation); + if (binding === undefined || thread?.runtimeIdentity === undefined) { + if (producer !== undefined) + producer.pendingReroutes.set( + payload.threadId, + [ + ...(producer.pendingReroutes.get(payload.threadId) ?? []), + payload.toModel, + ].slice(-32), + ); + return; + } + yield* emitProviderEvent({ + type: "runtime_identity.observed", + driver: CODEX_PROVIDER, + binding, + requested: thread.runtimeIdentity.requested, + observed: { + ...thread.runtimeIdentity.observed, + model: { + status: "observed", + value: payload.toModel, + sourceEvent: "model/rerouted", + }, + }, + }); + }), ); yield* client.handleServerNotification("turn/started", (payload) => @@ -5799,7 +6091,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi if (!request.recoveryEnabled) return; if ( current._tag === "Failure" || - current.value.revision !== resolvedRuntime?.revision || + current.value.revision !== currentProducer.resolvedRuntime?.revision || !currentCapacityBinding(request) ) { yield* cancelCapacityRequest(request, "runtime_changed", true); @@ -6012,16 +6304,30 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }), ), ), - Effect.map((response): OrchestrationV2ProviderThread => - providerThreadFromCodexThread({ + Effect.flatMap((response) => { + const native = providerThreadFromCodexThread({ appThreadId: threadInput.threadId, idAllocator, - ownerNodeId: null, + ownerNodeId: threadInput.existingProviderThread?.ownerNodeId ?? null, providerSessionId: input.providerSessionId, providerInstanceId: adapterOptions.instanceId, thread: response.thread, - }), - ), + }); + const thread = + threadInput.existingProviderThread === undefined + ? native + : { + ...threadInput.existingProviderThread, + ...native, + id: threadInput.existingProviderThread.id, + }; + return bindRuntimeThread( + currentProducer, + thread, + threadInput.modelSelection, + codexObservedRuntimeIdentity(response), + ); + }), Effect.mapError( (cause) => new ProviderAdapterEnsureThreadError({ @@ -6079,19 +6385,32 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), Effect.flatMap(decodeCodexResumeMetadata), ); - return { - ...threadInput.providerThread, - providerSessionId: input.providerSessionId, - providerInstanceId: adapterOptions.instanceId, - status: "idle", - nativeThreadRef: { + if (response.thread.id !== nativeThreadId) { + nativeStartUnknown = true; + return yield* new ProviderRuntimeBindingError({ driver: CODEX_PROVIDER, - nativeId: response.thread.id, - strength: "strong", + detail: + "Codex resumed a different native conversation; continuation is unconfirmed.", + }); + } + return yield* bindRuntimeThread( + currentProducer, + { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + providerInstanceId: adapterOptions.instanceId, + status: "idle", + nativeThreadRef: { + driver: CODEX_PROVIDER, + nativeId: response.thread.id, + strength: "strong", + }, + nativeConversationHeadRef: threadInput.providerThread.nativeConversationHeadRef, + updatedAt: codexTimestamp(response.thread.updatedAt), }, - nativeConversationHeadRef: threadInput.providerThread.nativeConversationHeadRef, - updatedAt: codexTimestamp(response.thread.updatedAt), - } satisfies OrchestrationV2ProviderThread; + threadInput.modelSelection ?? input.modelSelection, + codexObservedRuntimeIdentity(response), + ); }).pipe( Effect.mapError( (cause) => @@ -6214,7 +6533,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi attemptId: turnInput.attemptId, providerThreadId: turnInput.providerThread.id, nativeThreadId: threadId, - runtimeGeneration: input.providerSessionId, + runtimeGeneration: currentProducer.generation, }), input: turnInput, params: turnStartParams, @@ -7006,15 +7325,38 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi // process. After a restart or idle release, load it the same way // the next turn would before reverting. if (!loaded) { - yield* client.raw.request("thread/resume", { - threadId, - excludeTurns: true, - ...codexThreadRuntimeParams({ - threadId: threadInput.providerThread.appThreadId, - modelSelection: input.modelSelection, - runtimePolicy: input.runtimePolicy, - }), - }); + const resumed = yield* client.raw + .request("thread/resume", { + threadId, + excludeTurns: true, + ...codexThreadRuntimeParams({ + threadId: threadInput.providerThread.appThreadId, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }), + }) + .pipe(Effect.flatMap(decodeCodexResumeMetadata)); + if (resumed.thread.id !== threadId) { + nativeStartUnknown = true; + return yield* new ProviderRuntimeBindingError({ + driver: CODEX_PROVIDER, + detail: + "Codex rollback resumed another native conversation; its effect is unconfirmed.", + }); + } + yield* bindRuntimeThread( + currentProducer, + threadInput.providerThread, + input.modelSelection, + codexObservedRuntimeIdentity(resumed), + ); + } else if (!currentProducer.bindings.has(threadId)) { + yield* bindRuntimeThread( + currentProducer, + threadInput.providerThread, + input.modelSelection, + unobservedRuntimeIdentity(), + ); } const response = yield* ensureInitialized.pipe( Effect.andThen(revertCodexThread(client, threadId, numTurns)), @@ -7022,7 +7364,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi turnTokenUsageByThread.delete(threadId); return { providerThread: { - ...threadInput.providerThread, + ...(currentProducer.bindings.get(threadId) ?? threadInput.providerThread), nativeThreadRef: { driver: CODEX_PROVIDER, nativeId: response.thread.id, @@ -7050,6 +7392,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi forkThread: (threadInput) => Effect.gen(function* () { const threadId = yield* getNativeThreadId(threadInput.sourceProviderThread); + if (nativeStartUnknown || capacityScopeClosed) + return yield* toProtocolError("Cannot fork an unconfirmed Codex runtime."); + const issuer = currentProducer; const boundary = yield* resolveCodexForkBoundary(threadInput); const response = yield* ensureInitialized.pipe( Effect.andThen( @@ -7078,6 +7423,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }), ), ); + nativeStartUnknown = true; let forkedThread = response.thread; if (boundary.rollbackTurnCount > 0) { // Reached only when the selected source turn has no native @@ -7107,6 +7453,32 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), )).thread; } + const currentRuntime = + adapterOptions.resolveRuntime === undefined + ? undefined + : yield* adapterOptions.resolveRuntime.pipe( + Effect.scoped, + Effect.timeout("30 seconds"), + ); + if ( + issuer !== currentProducer || + !issuer.active || + currentRuntime?.revision !== issuer.resolvedRuntime?.revision || + forkedThread.id.trim().length === 0 || + forkedThread.id === threadId + ) { + nativeStartUnknown = true; + return yield* toProtocolError( + "Codex fork has an unconfirmed issuer or native ID; the fork effect must not be replayed.", + ); + } + pendingForkBindings.set(forkedThread.id, { + producer: issuer, + targetThreadId: threadInput.targetThreadId, + selection: threadInput.modelSelection ?? input.modelSelection, + observed: codexObservedRuntimeIdentity(response), + }); + nativeStartUnknown = false; return providerThreadFromCodexThread({ appThreadId: threadInput.targetThreadId, idAllocator, @@ -7132,7 +7504,202 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), ), }; - return runtime; + const lifecyclePermit = yield* Semaphore.make(1); + const prepareProducer = (turnInput: ProviderAdapterV2TurnInput) => + Effect.gen(function* () { + if (nativeStartUnknown || capacityScopeClosed) + return yield* toProtocolError( + "Codex has an unconfirmed native effect; another prompt is not safe.", + ); + const nativeId = yield* getNativeThreadId(turnInput.providerThread); + const pending = pendingForkBindings.get(nativeId); + if (pending !== undefined) { + if (pending.targetThreadId !== turnInput.threadId) + return yield* toProtocolError( + "This native fork belongs to another application thread.", + ); + if (pending.producer !== currentProducer || !pending.producer.active) { + pendingForkBindings.delete(nativeId); + return yield* toProtocolError( + "The native fork's issuer was replaced before its first turn.", + ); + } + const currentRuntime = + adapterOptions.resolveRuntime === undefined + ? undefined + : yield* adapterOptions.resolveRuntime.pipe( + Effect.scoped, + Effect.timeout("30 seconds"), + ); + if (currentRuntime?.revision !== pending.producer.resolvedRuntime?.revision) { + pendingForkBindings.delete(nativeId); + return yield* toProtocolError( + "The native fork's runtime revision changed before its first turn.", + ); + } + yield* bindRuntimeThread( + pending.producer, + turnInput.providerThread, + pending.selection, + pending.observed, + ); + pendingForkBindings.delete(nativeId); + } + if (adapterOptions.resolveRuntime !== undefined) { + const revision = yield* adapterOptions.resolveRuntime.pipe( + Effect.scoped, + Effect.timeout("30 seconds"), + ); + if (revision.revision !== currentProducer.resolvedRuntime?.revision) { + if (pending !== undefined) + return yield* toProtocolError( + "The native fork's runtime revision changed before its first prompt.", + ); + if ( + (yield* Ref.get(activeTurns)).size > 0 || + (yield* Ref.get(pendingRootTurns)).size > 0 || + capacityByThread.size > 0 || + (yield* runtime.hasPendingBackgroundWork!) + ) + return yield* toProtocolError( + "Codex runtime rotation is blocked by active or background work in the shared process.", + ); + const previous = currentProducer; + previous.active = false; + yield* Scope.close(previous.scope, Exit.void); + yield* input.runtimeLifecycle?.abandon(previous.generation) ?? Effect.void; + const opened = yield* openProducer( + turnInput.threadId, + turnInput.runtimePolicy, + ).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + nativeStartUnknown = true; + }), + ), + Effect.exit, + ); + if (opened._tag === "Failure") { + nativeStartUnknown = true; + return yield* Effect.failCause(opened.cause); + } + currentProducer = opened.value; + yield* Ref.set(initialized, false); + for (const install of registrations) yield* install(currentProducer); + } + } + const bound = currentProducer.bindings.get(nativeId); + if (bound === undefined || bound.id !== turnInput.providerThread.id) { + const resumed = yield* runtime + .resumeThread({ + providerThread: turnInput.providerThread, + threadId: turnInput.threadId, + modelSelection: turnInput.modelSelection, + runtimePolicy: turnInput.runtimePolicy, + }) + .pipe(Effect.exit); + if (resumed._tag === "Failure") { + nativeStartUnknown = true; + return yield* Effect.failCause(resumed.cause); + } + } else { + const next = identityForRequest( + requestedRuntimeIdentity(turnInput.modelSelection, CODEX_PROVIDER), + bound.runtimeIdentity, + ); + if ( + next.requested.model !== bound.runtimeIdentity?.requested.model || + next.requested.serviceTier !== bound.runtimeIdentity.requested.serviceTier + ) { + const updated = { ...bound, runtimeIdentity: next }; + if (input.runtimeLifecycle !== undefined) { + const rebound = yield* input.runtimeLifecycle.bind({ + providerThread: updated, + runtimeGeneration: currentProducer.generation, + requested: next.requested, + observed: unobservedRuntimeIdentity(), + }); + currentProducer.bindings.set(nativeId, rebound); + } else currentProducer.bindings.set(nativeId, updated); + } + } + }); + const withProducer = (effect: Effect.Effect) => + Effect.suspend(() => + effect.pipe(Effect.provideService(CodexProducerContext, currentProducer)), + ); + return { + ...runtime, + ensureThread: (value) => + lifecyclePermit.withPermits(1)( + Effect.suspend(() => + nativeStartUnknown + ? Effect.fail( + new ProviderRuntimeBindingError({ + driver: CODEX_PROVIDER, + detail: + "Codex has an unconfirmed native effect; starting a new conversation is not safe.", + }), + ) + : withProducer(runtime.ensureThread(value)), + ), + ), + resumeThread: (value) => + lifecyclePermit.withPermits(1)( + Effect.suspend(() => + nativeStartUnknown + ? Effect.fail( + new ProviderRuntimeBindingError({ + driver: CODEX_PROVIDER, + detail: + "Codex has an unconfirmed native effect; another resume is not safe.", + }), + ) + : withProducer(runtime.resumeThread(value)), + ), + ), + forkThread: (value) => + lifecyclePermit.withPermits(1)( + Effect.suspend(() => + nativeStartUnknown + ? toProtocolError( + "Codex has an unconfirmed native effect; reforking is not safe.", + ) + : withProducer(runtime.forkThread(value)).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + nativeStartUnknown = true; + }), + ), + ), + ), + ), + startTurn: (value) => + Effect.suspend(() => + nativeStartUnknown || capacityScopeClosed + ? toProtocolError( + "Codex has an unconfirmed native effect; another prompt is not safe.", + ) + : lifecyclePermit.withPermits(1)( + prepareProducer(value).pipe( + Effect.andThen(withProducer(runtime.startTurn(value))), + ), + ), + ).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: CODEX_PROVIDER, + threadId: value.threadId, + providerThreadId: value.providerThread.id, + runId: value.runId, + cause, + }), + ), + ), + interruptTurn: (value) => withProducer(runtime.interruptTurn(value)), + rollbackThread: (value) => withProducer(runtime.rollbackThread(value)), + } satisfies ProviderAdapterV2SessionRuntime; }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/EventSink.runtimeIdentity.test.ts b/apps/server/src/orchestration-v2/EventSink.runtimeIdentity.test.ts new file mode 100644 index 000000000..41c7c3fa1 --- /dev/null +++ b/apps/server/src/orchestration-v2/EventSink.runtimeIdentity.test.ts @@ -0,0 +1,404 @@ +import { assert, it } from "@effect/vitest"; +import { + EventId, + ThreadId, + ProviderThreadId, + OrchestrationV2AppThread, + OrchestrationV2ProviderThread, + type ProviderRuntimeEvidenceCapture, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +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 Schema from "effect/Schema"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as EventSink from "./EventSink.ts"; +import { unobservedRuntimeIdentity } from "./ProviderAdapter.ts"; +import * as EventStore from "./EventStore.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; + +const stores = Layer.merge(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provide(SqlitePersistenceMemory), +); +const testLayer = Layer.mergeAll( + stores, + EventSink.layer.pipe(Layer.provide(Layer.merge(stores, SqlitePersistenceMemory))), +); +const decodeThread = Schema.decodeUnknownSync(OrchestrationV2AppThread); +const decodeProviderThread = Schema.decodeUnknownSync(OrchestrationV2ProviderThread); +const now = DateTime.makeUnsafe("2026-09-01T00:00:00.000Z"); + +const seedOwner = (revision: number, generation = "producer-1") => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const projection = yield* ProjectionStore.ProjectionStoreV2; + const app = decodeThread({ + createdBy: "user", + creationSource: "web", + id: "app-thread", + projectId: "project", + title: "Runtime identity", + providerInstanceId: "codex", + modelSelection: { instanceId: "codex", model: "requested" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: "provider-thread", + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "app-thread" }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }); + const owner = decodeProviderThread({ + id: "provider-thread", + driver: "codex", + providerInstanceId: "codex", + providerSessionId: "session", + appThreadId: app.id, + ownerNodeId: null, + nativeThreadRef: { driver: "codex", nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + runtimeIdentity: { + runtimeGeneration: generation, + evidenceRevision: revision, + requested: { + providerInstanceId: "codex", + providerDriver: "codex", + model: "current-request", + serviceTier: null, + }, + observed: { + backend: { status: "unknown" }, + model: { status: "observed", value: "native-current", sourceEvent: "native" }, + account: { status: "unavailable", reason: "Not attested." }, + serviceTier: { status: "unavailable", reason: "Not reported." }, + }, + }, + }); + yield* sink.write({ + events: [ + { + id: EventId.make("thread-created"), + type: "thread.created", + threadId: app.id, + occurredAt: now, + payload: app, + }, + { + id: EventId.make("owner-bound"), + type: "provider-thread.updated", + threadId: app.id, + occurredAt: now, + payload: owner, + }, + ], + }); + if ( + owner.providerSessionId === null || + owner.nativeThreadRef === null || + owner.nativeThreadRef.nativeId === null + ) { + return yield* Effect.die(new Error("Runtime evidence fixture requires a native binding")); + } + const capture: ProviderRuntimeEvidenceCapture = { + threadId: app.id, + providerThreadId: owner.id, + providerSessionId: owner.providerSessionId, + providerInstanceId: owner.providerInstanceId, + driver: owner.driver, + nativeThreadId: owner.nativeThreadRef.nativeId, + runtimeGeneration: "producer-1", + evidenceRevision: 1, + }; + return { sink, projection, app, owner, capture }; + }); + +it.effect.each([ + { + title: "allows pinned generation evidence revision advance without blocking terminal state", + revision: 2, + pinned: true, + committed: true, + }, + { + title: "allows pinned generation evidence revision equality", + revision: 1, + pinned: true, + committed: true, + }, + { + title: "rejects pinned generation evidence revision regression", + revision: 0, + pinned: true, + committed: false, + }, + { + title: "rejects unpinned evidence revision advance", + revision: 2, + pinned: false, + committed: false, + }, + { + title: "allows unpinned evidence revision equality", + revision: 1, + pinned: false, + committed: true, + }, +])("$title", ({ revision, pinned, committed }) => + Effect.gen(function* () { + const { sink, projection, app, owner, capture } = yield* seedOwner(revision); + const { runtimeGeneration: _generation, ...unpinned } = capture; + const staleSnapshot = { + ...owner, + status: "idle" as const, + runtimeIdentity: { + ...owner.runtimeIdentity!, + requested: { ...owner.runtimeIdentity!.requested, model: "stale-request" }, + observed: { ...owner.runtimeIdentity!.observed, model: { status: "unknown" as const } }, + }, + }; + const stored = yield* sink.write({ + runtimeEvidence: pinned ? capture : unpinned, + events: [ + { + id: EventId.make("terminal-state"), + type: "provider-thread.updated", + threadId: app.id, + occurredAt: now, + payload: staleSnapshot, + }, + ], + }); + assert.equal(stored.length, committed ? 1 : 0); + const row = (yield* projection.getThreadRecords(app.id, ["providerThreads"])) + .providerThreads[0]!; + assert.equal(row.status, committed ? "idle" : "active"); + assert.deepEqual( + row.runtimeIdentity, + owner.runtimeIdentity, + "late snapshots must preserve newer requested and observed evidence", + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("rejects a producer replaced after normalization and before commit", () => + Effect.gen(function* () { + const { sink, projection, app, owner, capture } = yield* seedOwner(1); + const normalized = { + id: EventId.make("late-provider-state"), + type: "provider-thread.updated" as const, + threadId: app.id, + occurredAt: now, + payload: { ...owner, status: "idle" as const }, + }; + const normalizedReady = yield* Deferred.make(); + const commit = yield* Deferred.make(); + const writer = yield* Effect.gen(function* () { + yield* Deferred.succeed(normalizedReady, undefined); + yield* Deferred.await(commit); + return yield* sink.write({ runtimeEvidence: capture, events: [normalized] }); + }).pipe(Effect.forkChild); + yield* Deferred.await(normalizedReady); + yield* sink.write({ + runtimeIdentityBoundary: { expectedGeneration: "producer-1" }, + events: [ + { + id: EventId.make("replacement-boundary"), + type: "provider-thread.updated", + threadId: app.id, + occurredAt: now, + payload: { + ...owner, + runtimeIdentity: { ...owner.runtimeIdentity!, runtimeGeneration: "producer-2" }, + }, + }, + ], + }); + yield* Deferred.succeed(commit, undefined); + assert.deepEqual(yield* Fiber.join(writer), []); + const current = (yield* projection.getThreadRecords(app.id, ["providerThreads"])) + .providerThreads[0]!; + assert.equal(current.status, "active"); + assert.equal(current.runtimeIdentity?.runtimeGeneration, "producer-2"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect.each([ + { title: "app thread", change: { threadId: "another-app" } }, + { title: "provider thread", change: { providerThreadId: "another-owner" } }, + { title: "generation", change: { runtimeGeneration: "other-producer" } }, + { title: "native thread", change: { nativeThreadId: "other-native" } }, + { title: "provider session", change: { providerSessionId: "other-session" } }, + { title: "provider instance", change: { providerInstanceId: "other-instance" } }, + { title: "driver", change: { driver: "claudeAgent" } }, +])("rejects captured runtime $title drift", ({ change }) => + Effect.gen(function* () { + const { sink, app, owner, capture } = yield* seedOwner(1); + if ("threadId" in change) { + const otherThreadId = ThreadId.make(change.threadId); + yield* sink.write({ + events: [ + { + id: EventId.make("other-thread-created"), + type: "thread.created", + threadId: otherThreadId, + occurredAt: now, + payload: { + ...app, + id: otherThreadId, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: otherThreadId, + }, + }, + }, + ], + }); + } + const altered = { ...capture, ...change } as ProviderRuntimeEvidenceCapture; + assert.deepEqual( + yield* sink.write({ + runtimeEvidence: altered, + events: [ + { + id: EventId.make("stale-state"), + type: "provider-thread.updated", + threadId: app.id, + occurredAt: now, + payload: { ...owner, status: "idle" }, + }, + ], + }), + [], + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("rejects pinned runtime evidence whose owner row is missing", () => + Effect.gen(function* () { + const { sink, app, owner, capture } = yield* seedOwner(1); + assert.deepEqual( + yield* sink.write({ + runtimeEvidence: { + ...capture, + providerThreadId: ProviderThreadId.make("absent-owner"), + } as ProviderRuntimeEvidenceCapture, + events: [ + { + id: EventId.make("owner-missing"), + type: "provider-thread.updated", + threadId: app.id, + occurredAt: now, + payload: owner, + }, + ], + }), + [], + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect.each([ + { + title: "keeps a same-request start writable after observation-only revision progress", + changed: false, + stale: false, + }, + { + title: "clears observations for a new request after observation-only revision progress", + changed: true, + stale: false, + }, + { + title: "rejects a request writer after an intervening requested-configuration change", + changed: true, + stale: true, + }, +])("$title", ({ changed, stale }) => + Effect.gen(function* () { + const { sink, projection, app, owner, capture } = yield* seedOwner(2); + const requested = { + ...owner.runtimeIdentity!.requested, + model: changed ? "next-request" : "current-request", + }; + const stored = yield* sink.write({ + runtimeEvidence: capture, + runtimeIdentityRequest: requested, + runtimeIdentityPreviousRequest: { + ...owner.runtimeIdentity!.requested, + model: stale ? "older-request" : "current-request", + }, + events: [ + { + id: EventId.make("running-request"), + type: "provider-thread.updated", + threadId: app.id, + occurredAt: now, + payload: { + ...owner, + status: "active", + runtimeIdentity: { ...owner.runtimeIdentity!, requested }, + }, + }, + ], + }); + assert.equal(stored.length, stale ? 0 : 1); + const current = (yield* projection.getThreadRecords(app.id, ["providerThreads"])) + .providerThreads[0]!; + assert.equal( + current.runtimeIdentity?.requested.model, + stale ? "current-request" : requested.model, + ); + assert.equal(current.runtimeIdentity?.runtimeGeneration, "producer-1"); + assert.deepEqual( + current.runtimeIdentity?.observed, + !stale && changed ? unobservedRuntimeIdentity() : owner.runtimeIdentity!.observed, + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect( + "accepts pinned revision progress while rejecting the same unpinned producer capture", + () => + Effect.gen(function* () { + const { sink, app, owner, capture } = yield* seedOwner(2); + const event = { + type: "provider-thread.updated" as const, + threadId: app.id, + occurredAt: now, + payload: { ...owner, status: "idle" as const }, + }; + assert.lengthOf( + yield* sink.write({ + runtimeEvidence: capture, + events: [{ ...event, id: EventId.make("pinned-progress") }], + }), + 1, + ); + const { runtimeGeneration: _generation, ...unpinned } = capture; + assert.deepEqual( + yield* sink.write({ + runtimeEvidence: unpinned, + events: [{ ...event, id: EventId.make("unpinned-progress") }], + }), + [], + ); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 71b6f0ea9..9bdcecf61 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -1,5 +1,8 @@ import { CommandId, + type OrchestrationV2ProviderThread, + type ProviderRuntimeEvidenceCapture, + type RequestedRuntimeIdentity, type OrchestrationV2Run, OrchestrationV2DomainEvent, OrchestrationV2StoredEvent, @@ -22,6 +25,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { identityForRequest } from "./ProviderAdapter.ts"; import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts"; import { projectDomainEventForWire } from "./WireProjection.ts"; @@ -67,22 +71,72 @@ export class EventSinkStreamError extends Schema.TaggedError; }) => Effect.Effect, EventSinkV2Error>; readonly writeWithEffects: (input: { + readonly runtimeIdentityRequest?: RequestedRuntimeIdentity; + readonly runtimeIdentityPreviousRequest?: RequestedRuntimeIdentity; + readonly runtimeEvidence?: ProviderRuntimeEvidenceCapture; + readonly runtimeIdentityObservation?: RequestedRuntimeIdentity; + readonly runtimeIdentityBoundary?: { readonly expectedGeneration: string | null }; readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; readonly events: ReadonlyArray; readonly effects: ReadonlyArray; }) => Effect.Effect, EventSinkV2Error>; readonly writeIfRunCurrent: (input: { + readonly runtimeIdentityRequest?: RequestedRuntimeIdentity; + readonly runtimeIdentityPreviousRequest?: RequestedRuntimeIdentity; + readonly runtimeEvidence?: ProviderRuntimeEvidenceCapture; + readonly runtimeIdentityObservation?: RequestedRuntimeIdentity; + readonly runtimeIdentityBoundary?: { readonly expectedGeneration: string | null }; readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; readonly threadId: ThreadId; @@ -104,6 +158,11 @@ export interface EventSinkV2Shape { * a newer attempt that already claimed the thread. */ readonly writeIfProviderThreadOwner: (input: { + readonly runtimeIdentityRequest?: RequestedRuntimeIdentity; + readonly runtimeIdentityPreviousRequest?: RequestedRuntimeIdentity; + readonly runtimeEvidence?: ProviderRuntimeEvidenceCapture; + readonly runtimeIdentityObservation?: RequestedRuntimeIdentity; + readonly runtimeIdentityBoundary?: { readonly expectedGeneration: string | null }; readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; readonly providerThreadId: ProviderThreadId; @@ -305,6 +364,111 @@ const baseLayer: Layer.Layer< }); }); + const guardRuntimeIdentity = ( + input: Pick< + Parameters[0], + | "events" + | "runtimeEvidence" + | "runtimeIdentityObservation" + | "runtimeIdentityBoundary" + | "runtimeIdentityRequest" + | "runtimeIdentityPreviousRequest" + >, + ) => + Effect.gen(function* () { + const capture = input.runtimeEvidence; + const update = input.events.find((event) => event.type === "provider-thread.updated"); + if (input.runtimeIdentityBoundary !== undefined) { + if (update?.type !== "provider-thread.updated") return null; + const current = + (yield* projectionStore.getThreadRecords(update.threadId, [ + "providerThreads", + ])).providerThreads.find((thread) => thread.id === update.payload.id) ?? null; + if ( + (capture !== undefined && !runtimeEvidenceMatches(current, capture)) || + (current?.runtimeIdentity?.runtimeGeneration ?? null) !== + input.runtimeIdentityBoundary.expectedGeneration || + (current !== null && + (current.appThreadId !== update.payload.appThreadId || + current.providerInstanceId !== update.payload.providerInstanceId || + current.driver !== update.payload.driver)) + ) + return null; + return input.events.map((event) => + event.type === "provider-thread.updated" && event.payload.id === update.payload.id + ? { + ...event, + payload: { + ...event.payload, + runtimeIdentity: + event.payload.runtimeIdentity === undefined + ? undefined + : { + ...event.payload.runtimeIdentity, + evidenceRevision: (current?.runtimeIdentity?.evidenceRevision ?? 0) + 1, + }, + }, + } + : event, + ); + } + if (capture === undefined) return input.events; + const current = + (yield* projectionStore.getThreadRecords(capture.threadId, [ + "providerThreads", + ])).providerThreads.find((thread) => thread.id === capture.providerThreadId) ?? null; + if (!runtimeEvidenceMatches(current, capture) || current === null) return null; + const identity = current.runtimeIdentity!; + if ( + input.runtimeIdentityRequest !== undefined && + capture.evidenceRevision !== identity.evidenceRevision + ) { + const previous = input.runtimeIdentityPreviousRequest; + if ( + previous === undefined || + identity.requested.providerInstanceId !== previous.providerInstanceId || + identity.requested.providerDriver !== previous.providerDriver || + identity.requested.model !== previous.model || + identity.requested.serviceTier !== previous.serviceTier + ) + return null; + } + if (input.runtimeIdentityObservation !== undefined) { + const requested = input.runtimeIdentityObservation; + if ( + identity.requested.providerInstanceId !== requested.providerInstanceId || + identity.requested.providerDriver !== requested.providerDriver || + identity.requested.model !== requested.model || + identity.requested.serviceTier !== requested.serviceTier || + identity.evidenceRevision !== capture.evidenceRevision + ) + return null; + } + return input.events.map((event) => + event.type === "provider-thread.updated" && event.payload.id === current.id + ? { + ...event, + payload: { + ...event.payload, + runtimeIdentity: + input.runtimeIdentityObservation === undefined + ? input.runtimeIdentityRequest === undefined + ? identity + : { + ...identityForRequest(input.runtimeIdentityRequest, identity), + evidenceRevision: (identity.evidenceRevision ?? 0) + 1, + } + : { + ...identity, + observed: event.payload.runtimeIdentity!.observed, + evidenceRevision: (identity.evidenceRevision ?? 0) + 1, + }, + }, + } + : event, + ); + }); + const normalizeEvents = (events: ReadonlyArray) => { const runOrdinals = new Map( events.flatMap((event) => @@ -369,10 +533,12 @@ const baseLayer: Layer.Layer< return yield* commitThenPublish( Effect.gen(function* () { + const identityEvents = yield* guardRuntimeIdentity(input); + if (identityEvents === null) return []; const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true - ? yield* guardUserInputCancellations(input.events) - : input.events, + ? yield* guardUserInputCancellations(identityEvents) + : identityEvents, ); const committed = yield* eventStore.append({ ...(input.commandId === undefined ? {} : { commandId: input.commandId }), @@ -427,10 +593,16 @@ const baseLayer: Layer.Layer< }; } + const identityEvents = yield* guardRuntimeIdentity(input); + if (identityEvents === null) + return { + committed: false as const, + storedEvents: [] as ReadonlyArray, + }; const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true - ? yield* guardUserInputCancellations(input.events) - : input.events, + ? yield* guardUserInputCancellations(identityEvents) + : identityEvents, ); const storedEvents = yield* eventStore.append({ ...(input.commandId === undefined ? {} : { commandId: input.commandId }), @@ -484,10 +656,16 @@ const baseLayer: Layer.Layer< }; } + const identityEvents = yield* guardRuntimeIdentity(input); + if (identityEvents === null) + return { + committed: false as const, + storedEvents: [] as ReadonlyArray, + }; const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true - ? yield* guardUserInputCancellations(input.events) - : input.events, + ? yield* guardUserInputCancellations(identityEvents) + : identityEvents, ); const storedEvents = yield* eventStore.append({ ...(input.commandId === undefined ? {} : { commandId: input.commandId }), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3bb7617ee..9f0b8118d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -8,6 +8,7 @@ import { CheckpointScopeId, MessageId, type ModelSelection, + type OrchestrationV2ProviderThread, NodeId, ProjectId, ProviderDriverKind, @@ -24,6 +25,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; @@ -4468,3 +4470,157 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { }), ); }); + +it.effect.each([ + { backend: "SQL", recorded: false }, + { backend: "SQL", recorded: true }, + { backend: "memory", recorded: false }, + { backend: "memory", recorded: true }, +])( + "preserves selected runtime identity and historical absence across $backend full/detail/shell projections: $recorded", + ({ backend, recorded }) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate(`identity-${backend}-${recorded}`); + const now = yield* DateTime.now; + const sessionId = ProviderSessionId.make("shared-codex-session"); + const makeOwner = (suffix: string, model: string): OrchestrationV2ProviderThread => ({ + id: ProviderThreadId.make(`identity-owner-${suffix}`), + driver, + providerInstanceId, + providerSessionId: sessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: `native-${suffix}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + ...(recorded + ? { + runtimeIdentity: { + runtimeGeneration: "shared-process", + evidenceRevision: 2, + requested: { + providerInstanceId, + providerDriver: driver, + model: `requested-${model}`, + serviceTier: null, + }, + observed: { + backend: { + status: "observed", + value: "native-backend", + sourceEvent: "codex.thread/open", + }, + model: { status: "observed", value: model, sourceEvent: "codex.thread/open" }, + account: { status: "unavailable", reason: "Not bound." }, + serviceTier: { status: "unavailable", reason: "Not reported." }, + }, + }, + } + : {}), + }); + const selected = makeOwner("selected", "native-selected"); + const sibling = makeOwner("sibling", "native-sibling"); + yield* store.apply({ + id: EventId.make("identity-sibling"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: sibling, + }); + yield* store.apply({ + id: EventId.make("identity-selected"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: selected, + }); + const full = yield* store.getThreadProjection(threadId); + const detail = yield* store.getThreadSnapshotWindow(threadId, { rowLimit: 50 }); + assert.deepEqual( + full.providerThreads.find((row) => row.id === selected.id)?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + detail.projection.providerThreads.find((row) => row.id === selected.id)?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + (yield* store.getThreadProviderContext(threadId, providerInstanceId)).providerThreads.find( + (row) => row.id === selected.id, + )?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + (yield* store.getRuntimeRecoveryProjection(threadId)).providerThreads.find( + (row) => row.id === selected.id, + )?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + (yield* store.getThreadShell(threadId))?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + (yield* store.getShellSnapshot()).threads.find((row) => row.id === threadId) + ?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + (yield* store.getShellSnapshot({ unsettledOnly: true })).threads.find( + (row) => row.id === threadId, + )?.runtimeIdentity, + selected.runtimeIdentity, + ); + if (backend === "SQL") { + const sql = yield* Effect.serviceOption(SqlClient.SqlClient); + if (Option.isNone(sql)) + return yield* Effect.die(new Error("SQL fixture requires SqlClient")); + const reopened = yield* Effect.service(ProjectionStore.ProjectionStoreV2).pipe( + Effect.provide(Layer.fresh(ProjectionStore.layer)), + Effect.provideService(SqlClient.SqlClient, sql.value), + ); + assert.deepEqual( + (yield* reopened.getThreadRecords(threadId, ["providerThreads"])).providerThreads.find( + (row) => row.id === selected.id, + )?.runtimeIdentity, + selected.runtimeIdentity, + ); + assert.deepEqual( + (yield* reopened.getThreadShell(threadId))?.runtimeIdentity, + selected.runtimeIdentity, + ); + } + const app = yield* store.getThread(threadId); + yield* store.apply({ + id: EventId.make("identity-archive"), + type: "thread.archived", + threadId, + occurredAt: now, + payload: { ...app, archivedAt: now }, + }); + assert.deepEqual( + (yield* store.getShellSnapshot({ location: "archive" })).archivedThreads.find( + (row) => row.id === threadId, + )?.runtimeIdentity, + selected.runtimeIdentity, + ); + yield* store.apply({ + id: EventId.make("identity-foreign-owner"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { ...selected, appThreadId: ThreadId.make("foreign-app") }, + }); + assert.isUndefined( + (yield* store.getThreadShell(threadId))?.runtimeIdentity, + "a foreign row must not supply the app thread's identity", + ); + }).pipe(Effect.provide(backend === "SQL" ? TestLayer : ProjectionStore.layerMemory)), +); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a6be30799..cb2150834 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1302,6 +1302,18 @@ function buildVisibleTurnItems(input: { ]); } +function runtimeIdentityForShell( + thread: OrchestrationV2ThreadProjection["thread"], + providerThreads: ReadonlyArray, +) { + return providerThreads.find( + (provider) => + provider.id === thread.activeProviderThreadId && + provider.appThreadId === thread.id && + provider.providerInstanceId === thread.providerInstanceId, + )?.runtimeIdentity; +} + export function threadShellFromProjection( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2ThreadShell { @@ -1372,6 +1384,11 @@ export function threadShellFromProjection( lineage: projection.thread.lineage, forkedFrom: projection.thread.forkedFrom, activeProviderThreadId: projection.thread.activeProviderThreadId, + ...(runtimeIdentityForShell(projection.thread, projection.providerThreads) === undefined + ? {} + : { + runtimeIdentity: runtimeIdentityForShell(projection.thread, projection.providerThreads), + }), ...(projection.thread.historyOrigin === undefined ? {} : { historyOrigin: projection.thread.historyOrigin }), @@ -1490,6 +1507,7 @@ type ShellThreadState = { readonly hasActionableProposedPlan: boolean; readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"]; readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"]; + readonly runtimeIdentity: OrchestrationV2ThreadShell["runtimeIdentity"]; readonly itemCount: number; readonly runlessItemCount: number; readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; @@ -1608,6 +1626,9 @@ function shellFromState(input: { lineage: input.state.thread.lineage, forkedFrom: input.state.thread.forkedFrom, activeProviderThreadId: input.state.thread.activeProviderThreadId, + ...(input.state.runtimeIdentity === undefined + ? {} + : { runtimeIdentity: input.state.runtimeIdentity }), ...(input.state.thread.historyOrigin === undefined ? {} : { historyOrigin: input.state.thread.historyOrigin }), @@ -2910,7 +2931,8 @@ export const layer: Layer.Layer = ` : sql` SELECT payload_json FROM orchestration_v2_projection_provider_threads - WHERE (thread_id = ${threadId} AND status = 'active') + WHERE (thread_id = ${threadId} AND (status = 'active' + OR provider_thread_id = json_extract(${threadRow.payload_json}, '$.activeProviderThreadId'))) OR provider_thread_id IN (SELECT value FROM json_each(${cohortProviderThreadIds})) OR owner_node_id IN (SELECT value FROM json_each(${cohortNodeIds})) ORDER BY COALESCE(first_run_ordinal, 0), provider_thread_id ASC @@ -3949,6 +3971,8 @@ export const layer: Layer.Layer = ) AND ( provider_thread.status = 'active' + OR (provider_thread.thread_id = ${threadId} + AND provider_thread.provider_thread_id = json_extract(${threadRows[0].payload_json}, '$.activeProviderThreadId')) OR CASE WHEN json_valid(provider_thread.payload_json) THEN json_array_length(provider_thread.payload_json, '$.pendingBackgroundTasks') > 0 ELSE 0 END @@ -5360,6 +5384,10 @@ export const layer: Layer.Layer = : DateTime.makeUnsafe(row.latest_user_authored_message_at), hasActionableProposedPlan: row.has_actionable_proposed_plan === 1, pendingBackgroundTasks, + runtimeIdentity: runtimeIdentityForShell( + thread, + providerThreadsByThreadId.get(thread.id) ?? [], + ), providerInstanceHistory: providerInstanceHistoryForShell({ threadId: thread.id, providerThreads: providerThreadsByThreadId.get(thread.id) ?? [], diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index ac3000043..c697dbc2a 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -4,6 +4,11 @@ import { CheckpointId, MessageId, ModelSelection, + ObservedRuntimeIdentity, + ProviderRuntimeBinding, + ProviderRuntimeEvidenceCapture, + RequestedRuntimeIdentity, + RuntimeIdentityAttestation, NodeId, OrchestrationV2AppThread, OrchestrationV2ConversationMessage, @@ -76,61 +81,79 @@ export const ProviderAdapterV2SessionStatus = Schema.Literals([ export type ProviderAdapterV2SessionStatus = typeof ProviderAdapterV2SessionStatus.Type; export const ProviderAdapterV2Event = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("runtime_identity.observed"), + driver: ProviderDriverKind, + binding: ProviderRuntimeBinding, + requested: RequestedRuntimeIdentity, + observed: ObservedRuntimeIdentity, + }), Schema.Struct({ type: Schema.Literal("app_thread.created"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), appThread: OrchestrationV2AppThread, }), Schema.Struct({ type: Schema.Literal("provider_session.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), providerSession: OrchestrationV2ProviderSession, }), Schema.Struct({ type: Schema.Literal("provider_thread.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), providerThread: OrchestrationV2ProviderThread, }), Schema.Struct({ type: Schema.Literal("provider_turn.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), threadId: Schema.optional(ThreadId), providerTurn: OrchestrationV2ProviderTurn, }), Schema.Struct({ type: Schema.Literal("node.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), node: OrchestrationV2ExecutionNode, }), Schema.Struct({ type: Schema.Literal("subagent.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), subagent: OrchestrationV2Subagent, }), Schema.Struct({ type: Schema.Literal("message.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), message: OrchestrationV2ConversationMessage, }), Schema.Struct({ type: Schema.Literal("turn_item.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), turnItem: OrchestrationV2TurnItem, }), Schema.Struct({ type: Schema.Literal("runtime_request.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), threadId: Schema.optional(ThreadId), runtimeRequest: OrchestrationV2RuntimeRequest, }), Schema.Struct({ type: Schema.Literal("plan.updated"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), plan: OrchestrationV2PlanArtifact, }), Schema.Struct({ type: Schema.Literal("turn.terminal"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), providerThreadId: ProviderThreadId, providerTurnId: ProviderTurnId, runOrdinal: PositiveInt, @@ -141,6 +164,7 @@ export const ProviderAdapterV2Event = Schema.Union([ Schema.Struct({ type: Schema.Literal("turn.terminal"), driver: ProviderDriverKind, + runtimeEvidence: Schema.optional(ProviderRuntimeEvidenceCapture), providerThreadId: ProviderThreadId, providerTurnId: ProviderTurnId, runOrdinal: PositiveInt, @@ -354,7 +378,22 @@ export class ProviderAdapterProtocolError extends Schema.TaggedError()( + "ProviderRuntimeBindingError", + { driver: ProviderDriverKind, detail: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) {} + +export function hasUnknownRuntimeBinding(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth += 1) { + if (Reflect.get(current, "_tag") === "ProviderRuntimeBindingError") return true; + current = Reflect.get(current, "cause"); + } + return false; +} + export const ProviderAdapterV2Error = Schema.Union([ + ProviderRuntimeBindingError, ProviderAdapterCapabilitiesError, ProviderAdapterOpenSessionError, ProviderAdapterCloseSessionError, @@ -373,7 +412,92 @@ export const ProviderAdapterV2Error = Schema.Union([ ]); export type ProviderAdapterV2Error = typeof ProviderAdapterV2Error.Type; +export interface ProviderRuntimeLifecycle { + readonly reserve: (threadId: ThreadId) => Effect.Effect; + readonly bind: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly runtimeGeneration: string; + readonly requested: RequestedRuntimeIdentity; + readonly observed: ObservedRuntimeIdentity; + }) => Effect.Effect; + readonly abandon: (runtimeGeneration: string) => Effect.Effect; + readonly invalidate: ( + binding: ProviderRuntimeBinding, + ) => Effect.Effect; +} + +export function requestedRuntimeIdentity( + selection: ModelSelection, + driver: ProviderDriverKind, +): RequestedRuntimeIdentity { + const tier = selection.options?.find((option) => option.id === "serviceTier")?.value; + return { + providerInstanceId: selection.instanceId, + providerDriver: driver, + model: selection.model, + serviceTier: typeof tier === "string" ? tier : null, + }; +} + +export function unobservedRuntimeIdentity(): ObservedRuntimeIdentity { + return { + backend: { status: "unknown" }, + model: { status: "unknown" }, + account: { + status: "unavailable", + reason: "No supported provider event safely binds an account to this runtime.", + }, + serviceTier: { status: "unknown" }, + }; +} + +export function runtimeBinding( + thread: OrchestrationV2ProviderThread, + runtimeGeneration: string, +): ProviderRuntimeBinding | undefined { + if ( + thread.appThreadId === null || + thread.providerSessionId === null || + thread.nativeThreadRef === null || + thread.nativeThreadRef.nativeId === null + ) + return undefined; + return { + threadId: thread.appThreadId, + providerThreadId: thread.id, + providerSessionId: thread.providerSessionId, + providerInstanceId: thread.providerInstanceId, + driver: thread.driver, + nativeThreadId: thread.nativeThreadRef.nativeId, + runtimeGeneration, + }; +} + +export function identityForRequest( + requested: RequestedRuntimeIdentity, + previous?: RuntimeIdentityAttestation, +): RuntimeIdentityAttestation { + const sameOwner = + previous?.requested.providerInstanceId === requested.providerInstanceId && + previous.requested.providerDriver === requested.providerDriver; + const sameRequest = + sameOwner && + previous.requested.model === requested.model && + previous.requested.serviceTier === requested.serviceTier; + return { + ...(sameOwner && previous.runtimeGeneration !== undefined + ? { runtimeGeneration: previous.runtimeGeneration } + : {}), + ...(sameOwner && previous.evidenceRevision !== undefined + ? { evidenceRevision: previous.evidenceRevision } + : {}), + requested, + observed: sameRequest ? previous.observed : unobservedRuntimeIdentity(), + }; +} + export interface ProviderAdapterV2OpenSessionInput { + readonly runtimeLifecycle?: ProviderRuntimeLifecycle; readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly modelSelection: ModelSelection; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 6bbfecf31..bd11a5476 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -13,6 +13,7 @@ import { type OrchestrationV2TurnItem, ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, PlanId, RunAttemptId, RunId, @@ -34,6 +35,7 @@ import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { unobservedRuntimeIdentity, requestedRuntimeIdentity } from "./ProviderAdapter.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; import { makeProviderEventRoutingState, @@ -1339,3 +1341,244 @@ layer("ProviderEventIngestorV2", (it) => { }), ); }); + +it.effect.each([ + { + title: "ignores runtime observations without a matching provider instance", + variant: "missing-instance", + }, + { + title: "ignores runtime observations from a stale provider instance", + variant: "stale-instance", + }, + { + title: "ignores runtime observations without a current generation", + variant: "missing-generation", + }, + { + title: "ignores runtime observations from an abandoned generation", + variant: "stale-generation", + }, + { title: "ignores runtime observations from another driver", variant: "wrong-driver" }, + { + title: "ignores runtime observations without a matching native conversation", + variant: "missing-native", + }, +] as const)("$title", ({ variant }) => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const created = yield* threadCreatedEvent(now); + const session = yield* ids.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: created.threadId, + }); + const requested = requestedRuntimeIdentity(modelSelection, CODEX_DRIVER); + const row: OrchestrationV2ProviderThread = { + id: ids.derive.providerThread({ driver: CODEX_DRIVER, nativeThreadId: "native-thread" }), + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerSessionId: session, + appThreadId: created.threadId, + ownerNodeId: null, + nativeThreadRef: { driver: CODEX_DRIVER, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + runtimeIdentity: { + ...(variant === "missing-generation" ? {} : { runtimeGeneration: "current-generation" }), + evidenceRevision: 1, + requested, + observed: unobservedRuntimeIdentity(), + }, + }; + yield* sink.write({ + events: [ + created, + { + id: yield* ids.allocate.event({ threadId: created.threadId }), + type: "provider-thread.updated", + threadId: created.threadId, + occurredAt: now, + payload: row, + }, + ], + }); + const observation = { + ...unobservedRuntimeIdentity(), + model: { + status: "observed" as const, + value: "rerouted-native", + sourceEvent: "codex.model/rerouted", + }, + }; + const stored = yield* ingestor.ingestNormalized({ + providerSessionId: session, + providerInstanceId: + variant === "missing-instance" + ? ProviderInstanceId.make("unregistered") + : modelSelection.instanceId, + threadId: created.threadId, + event: { + type: "runtime_identity.observed", + driver: CODEX_DRIVER, + binding: { + threadId: created.threadId, + providerThreadId: + variant === "missing-native" ? ProviderThreadId.make("absent-native-owner") : row.id, + providerSessionId: session, + providerInstanceId: + variant === "stale-instance" + ? ProviderInstanceId.make("other-instance") + : modelSelection.instanceId, + driver: + variant === "wrong-driver" ? ProviderDriverKind.make("claude-code") : CODEX_DRIVER, + nativeThreadId: "native-thread", + runtimeGeneration: + variant === "stale-generation" ? "abandoned-generation" : "current-generation", + }, + requested, + observed: observation, + }, + }); + assert.deepEqual(stored, []); + assert.deepEqual( + (yield* store.getThreadProjection(created.threadId)).providerThreads[0]?.runtimeIdentity + ?.observed, + unobservedRuntimeIdentity(), + ); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect( + "accepts a genuine native reroute but never attests model and tier from adapter turn metadata", + () => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const created = yield* threadCreatedEvent(now); + const session = yield* ids.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: created.threadId, + }); + const requested = requestedRuntimeIdentity(modelSelection, CODEX_DRIVER); + const row: OrchestrationV2ProviderThread = { + id: ids.derive.providerThread({ driver: CODEX_DRIVER, nativeThreadId: "native-thread" }), + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerSessionId: session, + appThreadId: created.threadId, + ownerNodeId: null, + nativeThreadRef: { driver: CODEX_DRIVER, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + runtimeIdentity: { + runtimeGeneration: "actual-process", + evidenceRevision: 1, + requested, + observed: unobservedRuntimeIdentity(), + }, + }; + yield* sink.write({ + events: [ + created, + { + id: yield* ids.allocate.event({ threadId: created.threadId }), + type: "provider-thread.updated", + threadId: created.threadId, + occurredAt: now, + payload: row, + }, + ], + }); + const snapshot = yield* ingestor.ingestNormalized({ + providerSessionId: session, + providerInstanceId: modelSelection.instanceId, + threadId: created.threadId, + event: { + type: "provider_thread.updated", + driver: CODEX_DRIVER, + providerThread: { + ...row, + runtimeIdentity: { + ...row.runtimeIdentity!, + observed: { + ...unobservedRuntimeIdentity(), + model: { + status: "observed", + value: "argument-model", + sourceEvent: "turn/start parameters", + }, + }, + }, + }, + runtimeEvidence: { + threadId: created.threadId, + providerThreadId: row.id, + providerSessionId: session, + providerInstanceId: modelSelection.instanceId, + driver: CODEX_DRIVER, + nativeThreadId: "native-thread", + runtimeGeneration: "actual-process", + evidenceRevision: 1, + }, + }, + }); + assert.lengthOf(snapshot, 1); + assert.deepEqual( + (yield* store.getThreadProjection(created.threadId)).providerThreads[0]?.runtimeIdentity + ?.observed, + unobservedRuntimeIdentity(), + ); + const observed = { + ...unobservedRuntimeIdentity(), + model: { + status: "observed" as const, + value: "rerouted-native", + sourceEvent: "codex.model/rerouted", + }, + }; + yield* ingestor.ingestNormalized({ + providerSessionId: session, + providerInstanceId: modelSelection.instanceId, + threadId: created.threadId, + event: { + type: "runtime_identity.observed", + driver: CODEX_DRIVER, + binding: { + threadId: created.threadId, + providerThreadId: row.id, + providerSessionId: session, + providerInstanceId: modelSelection.instanceId, + driver: CODEX_DRIVER, + nativeThreadId: "native-thread", + runtimeGeneration: "actual-process", + }, + requested, + observed, + }, + }); + const identity = (yield* store.getThreadProjection(created.threadId)).providerThreads[0] + ?.runtimeIdentity; + assert.deepEqual(identity?.observed, observed); + assert.equal(identity?.requested.model, modelSelection.model); + assert.equal(identity?.observed.serviceTier.status, "unknown"); + }).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 01a34547a..29f33c8c2 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -389,6 +389,30 @@ export const layer: Layer.Layer< const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => Effect.gen(function* () { switch (input.event.type) { + case "runtime_identity.observed": { + const { binding, requested, observed } = input.event; + if ( + input.providerInstanceId !== binding.providerInstanceId || + input.providerSessionId !== binding.providerSessionId || + input.threadId !== binding.threadId || + input.event.driver !== binding.driver + ) + return []; + const current = (yield* projections.getThreadRecords(binding.threadId, [ + "providerThreads", + ])).providerThreads.find((thread) => thread.id === binding.providerThreadId); + if (current?.runtimeIdentity === undefined) return []; + return [ + yield* makeDomainEvent(input, { + type: "provider-thread.updated", + threadId: binding.threadId, + payload: { + ...current, + runtimeIdentity: { ...current.runtimeIdentity, requested, observed }, + }, + }), + ]; + } case "app_thread.created": return [ yield* makeDomainEvent(input, { @@ -554,6 +578,27 @@ export const layer: Layer.Layer< if (events.length === 0) { return []; } + const observation = + input.event.type === "runtime_identity.observed" ? input.event : undefined; + const identity = + observation === undefined + ? undefined + : events.find((event) => event.type === "provider-thread.updated"); + const runtimeGuard = + observation === undefined + ? "runtimeEvidence" in input.event && input.event.runtimeEvidence !== undefined + ? { runtimeEvidence: input.event.runtimeEvidence } + : {} + : { + runtimeEvidence: { + ...observation.binding, + ...(identity?.type === "provider-thread.updated" && + identity.payload.runtimeIdentity?.evidenceRevision !== undefined + ? { evidenceRevision: identity.payload.runtimeIdentity.evidenceRevision } + : {}), + }, + runtimeIdentityObservation: observation.requested, + }; const mapWriteError = (cause: unknown) => new ProviderEventPublishError({ providerSessionId: input.providerSessionId, @@ -564,6 +609,7 @@ export const layer: Layer.Layer< const ownerResult = yield* eventSink .writeIfProviderThreadOwner({ guardPendingUserInputCancellations: true, + ...runtimeGuard, ...(input.commandId === undefined ? {} : { commandId: input.commandId }), ...input.writeIfProviderThreadOwner, events, @@ -575,6 +621,7 @@ export const layer: Layer.Layer< return yield* eventSink .write({ guardPendingUserInputCancellations: true, + ...runtimeGuard, ...(input.commandId === undefined ? {} : { commandId: input.commandId }), events, }) @@ -583,6 +630,7 @@ export const layer: Layer.Layer< const result = yield* eventSink .writeIfRunCurrent({ guardPendingUserInputCancellations: true, + ...runtimeGuard, ...(input.commandId === undefined ? {} : { commandId: input.commandId }), threadId: input.threadId, ...input.writeIfRunCurrent, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 696a40d8d..2043ae857 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -50,6 +50,11 @@ import { type ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2SessionRuntime, type ProviderAdapterV2Shape, + type ProviderAdapterV2OpenSessionInput, + type ProviderRuntimeLifecycle, + unobservedRuntimeIdentity, + requestedRuntimeIdentity, + runtimeBinding, } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; @@ -287,10 +292,7 @@ function makeProviderAdapter( readonly mcpConfigs?: Ref.Ref< ReadonlyArray >; - readonly beforeOpen?: (input: { - readonly providerSessionId: ProviderSessionId; - readonly initialProviderItemIdentityVersion?: 2; - }) => Effect.Effect; + readonly beforeOpen?: (input: ProviderAdapterV2OpenSessionInput) => Effect.Effect; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly beforeUnload?: Effect.Effect; @@ -401,10 +403,7 @@ function makeTestLayer(input: { readonly mcpConfigs?: Ref.Ref< ReadonlyArray >; - readonly beforeOpen?: (input: { - readonly providerSessionId: ProviderSessionId; - readonly initialProviderItemIdentityVersion?: 2; - }) => Effect.Effect; + readonly beforeOpen?: (input: ProviderAdapterV2OpenSessionInput) => Effect.Effect; readonly failReleaseEventWrites?: boolean; readonly flakyReleaseWrites?: FlakyReleaseWrites; readonly hasPendingBackgroundWork?: Effect.Effect; @@ -449,6 +448,7 @@ function makeTestLayer(input: { configuredEventSinkLayer, IdAllocator.layer, TestMcpRegistryLayer, + providerEventIngestorTestLayer, ProviderSessionManager.layerWithOptions({ idleTimeoutMs: input.idleTimeoutMs, ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }), @@ -3478,3 +3478,347 @@ it.effect( assert.isFalse(denied?.capabilities?.has("device")); }), ); + +const runtimeBoundaryScenarios = [ + { operation: "recovery", outcome: "success" }, + { operation: "recovery", outcome: "failure" }, + { operation: "recovery", outcome: "interruption" }, + { operation: "rollback", outcome: "success" }, + { operation: "rollback", outcome: "failure" }, + { operation: "rollback", outcome: "interruption" }, + { operation: "managed rotation", outcome: "success" }, + { operation: "managed rotation", outcome: "failure" }, + { operation: "managed rotation", outcome: "interruption" }, +] as const; + +it.effect.each(runtimeBoundaryScenarios)( + "publishes a new generation before buffered observations for $operation $outcome", + ({ operation, outcome }) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const controller = yield* Ref.make(undefined); + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const sink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread-runtime-${operation}-${outcome}`); + const providerSessionId = yield* ids.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* sink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator: ids, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const lifecycle = yield* Ref.get(controller); + assert.isDefined(lifecycle); + if (lifecycle === undefined) + return yield* Effect.die(new Error("missing launch lifecycle")); + const row = makeProviderThread({ idAllocator: ids, threadId, providerSessionId, now }); + const requested = requestedRuntimeIdentity(modelSelection, CODEX_DRIVER); + const original = yield* lifecycle.reserve(threadId); + const originalObserved = { + ...unobservedRuntimeIdentity(), + model: { + status: "observed" as const, + value: "original-native", + sourceEvent: "thread/start", + }, + }; + const originalRow = yield* lifecycle.bind({ + providerThread: row, + runtimeGeneration: original, + requested, + observed: originalObserved, + }); + const candidate = yield* lifecycle.reserve(threadId); + assert.notEqual(candidate, original); + assert.equal( + (yield* store.getThreadProjection(threadId)).providerThreads[0]?.runtimeIdentity + ?.runtimeGeneration, + original, + "reserving before launch must not publish an unconfirmed generation", + ); + const candidateObserved = { + ...unobservedRuntimeIdentity(), + model: { + status: "observed" as const, + value: "replacement-native", + sourceEvent: "thread/resume", + }, + }; + if (outcome === "success") { + yield* lifecycle.bind({ + providerThread: originalRow, + runtimeGeneration: candidate, + requested, + observed: candidateObserved, + }); + const replay = yield* events.read({ threadId }).pipe(Stream.runCollect); + const writes = replay.flatMap((item) => + item.event.type === "provider-thread.updated" ? [item.event.payload] : [], + ); + const boundaryIndex = writes.findIndex( + (item) => item.runtimeIdentity?.runtimeGeneration === candidate, + ); + assert.isAtLeast(boundaryIndex, 0); + assert.deepEqual( + writes[boundaryIndex]?.runtimeIdentity?.observed, + unobservedRuntimeIdentity(), + ); + assert.deepEqual(writes[boundaryIndex + 1]?.runtimeIdentity?.observed, candidateObserved); + const stale = yield* ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + event: { + type: "runtime_identity.observed", + driver: CODEX_DRIVER, + binding: { + threadId, + providerThreadId: row.id, + providerSessionId, + providerInstanceId: modelSelection.instanceId, + driver: CODEX_DRIVER, + nativeThreadId: "native-thread", + runtimeGeneration: original, + }, + requested, + observed: originalObserved, + }, + }); + assert.deepEqual(stale, []); + yield* lifecycle.abandon(original); + assert.equal( + (yield* store.getThreadProjection(threadId)).providerThreads[0]?.runtimeIdentity + ?.runtimeGeneration, + candidate, + "closing the old issuer cannot invalidate the replacement", + ); + } else { + if (outcome === "interruption") { + const started = yield* Deferred.make(); + const fiber = yield* Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }).pipe( + Effect.ensuring(lifecycle.abandon(candidate).pipe(Effect.orDie)), + Effect.forkScoped, + ); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + } else yield* lifecycle.abandon(candidate); + assert.equal( + (yield* store.getThreadProjection(threadId)).providerThreads[0]?.runtimeIdentity + ?.runtimeGeneration, + original, + ); + assert.deepEqual( + (yield* store.getThreadProjection(threadId)).providerThreads[0]?.runtimeIdentity + ?.observed, + originalObserved, + ); + const failed = yield* lifecycle + .bind({ + providerThread: originalRow, + runtimeGeneration: candidate, + requested, + observed: candidateObserved, + }) + .pipe(Effect.result); + assert.equal( + failed._tag, + "Failure", + "an abandoned candidate cannot later become current", + ); + } + }).pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 60_000, + beforeOpen: (input) => Ref.set(controller, input.runtimeLifecycle), + }), + ), + ); + }), +); + +it.effect("keeps two native threads' requested and observed models separate in one process", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const controller = yield* Ref.make(undefined); + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const sink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const firstId = ThreadId.make("runtime-shared-first"); + const secondId = ThreadId.make("runtime-shared-second"); + const sessionId = yield* ids.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: firstId, + }); + yield* sink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator: ids, threadId: firstId, now }), + yield* makeThreadCreatedEvent({ idAllocator: ids, threadId: secondId, now }), + ], + }); + yield* manager.open({ + threadId: firstId, + providerSessionId: sessionId, + modelSelection, + runtimePolicy, + }); + const lifecycle = yield* Ref.get(controller); + if (lifecycle === undefined) return yield* Effect.die(new Error("missing launch lifecycle")); + const generation = yield* lifecycle.reserve(firstId); + const first = makeProviderThread({ + idAllocator: ids, + threadId: firstId, + providerSessionId: sessionId, + now, + }); + const second = { + ...makeProviderThread({ + idAllocator: ids, + threadId: secondId, + providerSessionId: sessionId, + now, + }), + id: ids.derive.providerThread({ driver: CODEX_DRIVER, nativeThreadId: "native-second" }), + nativeThreadRef: { + driver: CODEX_DRIVER, + nativeId: "native-second", + strength: "strong" as const, + }, + }; + const secondSelection = { ...modelSelection, model: "requested-second" }; + const firstBound = yield* lifecycle.bind({ + providerThread: first, + runtimeGeneration: generation, + requested: requestedRuntimeIdentity(modelSelection, CODEX_DRIVER), + observed: { + ...unobservedRuntimeIdentity(), + model: { status: "observed", value: "native-first", sourceEvent: "thread/start" }, + }, + }); + yield* lifecycle.bind({ + providerThread: second, + runtimeGeneration: generation, + requested: requestedRuntimeIdentity(secondSelection, CODEX_DRIVER), + observed: { + ...unobservedRuntimeIdentity(), + model: { status: "observed", value: "native-second", sourceEvent: "thread/start" }, + }, + }); + assert.equal( + (yield* store.getThreadProjection(firstId)).providerThreads[0]?.runtimeIdentity?.requested + .model, + modelSelection.model, + ); + assert.deepEqual( + (yield* store.getThreadProjection(secondId)).providerThreads[0]?.runtimeIdentity?.observed + .model, + { status: "observed", value: "native-second", sourceEvent: "thread/start" }, + ); + const changed = yield* lifecycle.bind({ + providerThread: firstBound, + runtimeGeneration: generation, + requested: requestedRuntimeIdentity( + { + ...modelSelection, + model: "changed", + options: [{ id: "serviceTier", value: "priority" }], + }, + CODEX_DRIVER, + ), + observed: unobservedRuntimeIdentity(), + }); + assert.equal(changed.runtimeIdentity?.runtimeGeneration, generation); + assert.equal(changed.runtimeIdentity?.requested.model, "changed"); + assert.deepEqual(changed.runtimeIdentity?.observed, unobservedRuntimeIdentity()); + assert.deepEqual( + (yield* store.getThreadProjection(secondId)).providerThreads[0]?.runtimeIdentity?.observed + .model, + { status: "observed", value: "native-second", sourceEvent: "thread/start" }, + ); + }).pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 60_000, + beforeOpen: (input) => Ref.set(controller, input.runtimeLifecycle), + }), + ), + ); + }), +); + +it.effect("refuses a missing native ID before committing a runtime boundary", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const controller = yield* Ref.make(undefined); + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const sink = yield* EventSink.EventSinkV2; + const store = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("runtime-missing-native-id"); + const providerSessionId = yield* ids.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* sink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator: ids, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const lifecycle = yield* Ref.get(controller); + if (lifecycle === undefined) return yield* Effect.die(new Error("missing launch lifecycle")); + const row = makeProviderThread({ idAllocator: ids, threadId, providerSessionId, now }); + const requested = requestedRuntimeIdentity(modelSelection, CODEX_DRIVER); + const originalGeneration = yield* lifecycle.reserve(threadId); + const current = yield* lifecycle.bind({ + providerThread: row, + runtimeGeneration: originalGeneration, + requested, + observed: unobservedRuntimeIdentity(), + }); + assert.equal(runtimeBinding(current, originalGeneration)?.nativeThreadId, "native-thread"); + const candidate = yield* lifecycle.reserve(threadId); + const missingNativeId = { + ...current, + nativeThreadRef: { driver: CODEX_DRIVER, nativeId: null, strength: "strong" as const }, + }; + assert.isUndefined(runtimeBinding(missingNativeId, candidate)); + const before = yield* events.read({ threadId }).pipe(Stream.runCollect); + const failure = yield* lifecycle + .bind({ + providerThread: missingNativeId, + runtimeGeneration: candidate, + requested, + observed: unobservedRuntimeIdentity(), + }) + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderRuntimeBindingError"); + assert.deepEqual((yield* store.getThreadProjection(threadId)).providerThreads, [current]); + assert.deepEqual(yield* events.read({ threadId }).pipe(Stream.runCollect), before); + }).pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 60_000, + beforeOpen: (input) => Ref.set(controller, input.runtimeLifecycle), + }), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 20e240e79..9fb359c8d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -40,6 +40,9 @@ import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import { ProviderAdapterEventStreamError, + ProviderRuntimeBindingError, + unobservedRuntimeIdentity, + type ProviderRuntimeLifecycle, ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Error, type ProviderAdapterV2Event, @@ -323,6 +326,193 @@ export const layerWithOptions = ( const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const makeRuntimeLifecycle = ( + providerSessionId: ProviderSessionId, + instanceId: ProviderInstanceId, + driver: ProviderAdapterV2SessionRuntime["driver"], + ): ProviderRuntimeLifecycle => { + const reservations = new Map< + string, + { + readonly previous: ReadonlyMap; + readonly bindings: Map; + } + >(); + const protocolError = (cause: unknown) => + new ProviderRuntimeBindingError({ + driver, + detail: + "Failed to establish the actual provider runtime binding; native effects must not be replayed.", + cause, + }); + const readThread = (threadId: ThreadId, id: string) => + projectionStore + .getThreadRecords(threadId, ["providerThreads"]) + .pipe( + Effect.map((records) => records.providerThreads.find((thread) => thread.id === id)), + ); + return { + reserve: (threadId) => + Effect.gen(function* () { + const rows = yield* projectionStore.getThreadRecords(threadId, ["providerThreads"]); + const generation = String( + yield* idAllocator.allocate.event({ threadId, providerSessionId }), + ); + reservations.set(generation, { + previous: new Map( + rows.providerThreads + .filter( + (thread) => + thread.providerInstanceId === instanceId && thread.driver === driver, + ) + .map((thread) => [ + String(thread.id), + thread.runtimeIdentity?.runtimeGeneration ?? null, + ]), + ), + bindings: new Map(), + }); + return generation; + }).pipe(Effect.mapError(protocolError)), + bind: (binding) => + Effect.gen(function* () { + const reservation = reservations.get(binding.runtimeGeneration); + const thread = binding.providerThread; + if ( + reservation === undefined || + thread.appThreadId === null || + thread.nativeThreadRef === null || + thread.nativeThreadRef.nativeId === null || + thread.nativeThreadRef.driver !== driver || + thread.driver !== driver || + thread.providerInstanceId !== instanceId || + thread.providerSessionId !== providerSessionId || + binding.requested.providerInstanceId !== instanceId || + binding.requested.providerDriver !== driver + ) + return yield* protocolError("The launch reservation or native ownership was lost."); + const current = yield* readThread(thread.appThreadId, thread.id); + const previous = current?.runtimeIdentity?.runtimeGeneration ?? null; + const expected = reservation.bindings.has(thread.id) + ? binding.runtimeGeneration + : (reservation.previous.get(thread.id) ?? null); + if (previous !== expected) + return yield* protocolError( + "Another producer already owns this native conversation.", + ); + const id = yield* idAllocator.allocate.event({ + threadId: thread.appThreadId, + providerSessionId, + }); + const boundaryThread = { + ...thread, + runtimeIdentity: { + runtimeGeneration: binding.runtimeGeneration, + requested: binding.requested, + observed: unobservedRuntimeIdentity(), + }, + }; + const boundary = yield* eventSink.write({ + runtimeIdentityBoundary: { expectedGeneration: expected }, + events: [ + { + id, + type: "provider-thread.updated", + threadId: thread.appThreadId, + occurredAt: yield* DateTime.now, + payload: boundaryThread, + }, + ], + }); + if (boundary.length === 0) + return yield* protocolError( + "The native binding changed before its boundary committed.", + ); + reservation.bindings.set(thread.id, thread.appThreadId); + yield* providerEventIngestor.ingestNormalized({ + providerSessionId, + providerInstanceId: instanceId, + threadId: thread.appThreadId, + event: { + type: "runtime_identity.observed", + driver, + binding: { + threadId: thread.appThreadId, + providerThreadId: thread.id, + providerSessionId, + providerInstanceId: instanceId, + driver, + nativeThreadId: thread.nativeThreadRef.nativeId, + runtimeGeneration: binding.runtimeGeneration, + }, + requested: binding.requested, + observed: binding.observed, + }, + }); + return (yield* readThread(thread.appThreadId, thread.id)) ?? boundaryThread; + }).pipe(Effect.mapError(protocolError)), + abandon: (generation) => + Effect.gen(function* () { + const reservation = reservations.get(generation); + reservations.delete(generation); + if (reservation === undefined) return; + for (const [id, threadId] of reservation.bindings) { + const current = yield* readThread(threadId, id); + if (current?.runtimeIdentity?.runtimeGeneration !== generation) continue; + yield* eventSink.write({ + runtimeIdentityBoundary: { expectedGeneration: generation }, + events: [ + { + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...current, + runtimeIdentity: { + requested: current.runtimeIdentity.requested, + observed: unobservedRuntimeIdentity(), + }, + }, + }, + ], + }); + } + }).pipe(Effect.mapError(protocolError)), + invalidate: (binding) => + Effect.gen(function* () { + const current = yield* readThread(binding.threadId, binding.providerThreadId); + if ( + current?.runtimeIdentity?.runtimeGeneration !== binding.runtimeGeneration || + current.nativeThreadRef?.nativeId !== binding.nativeThreadId + ) + return; + yield* eventSink.write({ + runtimeEvidence: binding, + runtimeIdentityBoundary: { expectedGeneration: binding.runtimeGeneration }, + events: [ + { + id: yield* idAllocator.allocate.event({ + threadId: binding.threadId, + providerSessionId, + }), + type: "provider-thread.updated", + threadId: binding.threadId, + occurredAt: yield* DateTime.now, + payload: { + ...current, + runtimeIdentity: { + requested: current.runtimeIdentity.requested, + observed: unobservedRuntimeIdentity(), + }, + }, + }, + ], + }); + }).pipe(Effect.mapError(protocolError)), + }; + }; + const agentAccessSettings = Effect.fn("ProviderSessionManagerV2.agentAccessSettings")( function* (threadId: ThreadId) { if (Option.isNone(serverSettings)) return { browser: true, device: false }; @@ -1562,6 +1752,26 @@ export const layerWithOptions = ( return entry.runtime.events.pipe( Stream.runForEach((event) => { if (shutdownSignal.received) return Effect.void; + if (event.type === "runtime_identity.observed") { + return providerEventIngestor + .ingestNormalized({ + providerSessionId: entry.runtime.providerSessionId, + providerInstanceId: entry.runtime.instanceId, + threadId: event.binding.threadId, + event, + }) + .pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new ProviderAdapterEventStreamError({ + driver: entry.runtime.driver, + providerSessionId: entry.runtime.providerSessionId, + cause, + }), + ), + ); + } if ( event.type === "provider_session.updated" && event.providerSession.status === "stopped" @@ -1754,6 +1964,11 @@ export const layerWithOptions = ( .openSession({ threadId: input.threadId, providerSessionId: input.providerSessionId, + runtimeLifecycle: makeRuntimeLifecycle( + input.providerSessionId, + input.modelSelection.instanceId, + adapter.driver, + ), modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, ...(input.resumeFromSession === undefined diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index fdb45f193..c2667f49d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -32,7 +32,7 @@ import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { ProviderAdapterEventStreamError } from "./ProviderAdapter.ts"; +import { ProviderAdapterEventStreamError, ProviderRuntimeBindingError } from "./ProviderAdapter.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; @@ -166,6 +166,7 @@ function makeLocalCommandHarness(input: { * fallback succeeds, then reading history for its handoff fails. */ readonly historyReadFailureAfterFallback?: unknown; + readonly unknownResumeBinding?: boolean; readonly interruptOpen?: boolean; readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; @@ -342,7 +343,7 @@ function makeLocalCommandHarness(input: { checkpoints: [], updatedAt: now, }; - if ("historyReadFailureAfterFallback" in input) { + if ("historyReadFailureAfterFallback" in input || input.unknownResumeBinding === true) { const nativeThreadRef = { driver: providerThread.driver, nativeId: "native-resume-thread", @@ -385,18 +386,23 @@ function makeLocalCommandHarness(input: { driver: providerThread.driver, resumeThread: () => Effect.fail( - new ProviderAdapterEventStreamError({ - driver: providerThread.driver, - providerSessionId, - cause: "native thread is gone", - }), + input.unknownResumeBinding === true + ? new ProviderRuntimeBindingError({ + driver: providerThread.driver, + detail: "Native launch succeeded but its binding is unknown.", + }) + : new ProviderAdapterEventStreamError({ + driver: providerThread.driver, + providerSessionId, + cause: "native thread is gone", + }), ), - ensureThread: () => Effect.succeed(providerThread), + ensureThread: vi.fn(() => Effect.succeed(providerThread)), }; const open = vi.fn(() => input.interruptOpen === true ? Effect.interrupt - : "historyReadFailureAfterFallback" in input + : "historyReadFailureAfterFallback" in input || input.unknownResumeBinding === true ? Effect.succeed(resumeFallbackSession as never) : "ensureThreadFailure" in input ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) @@ -532,6 +538,7 @@ function makeLocalCommandHarness(input: { ); return { open, + fallbackEnsure: resumeFallbackSession.ensureThread, writeIfRunCurrent, startRootRun, tryHandlePromptCommand, @@ -855,3 +862,22 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { }), ); } + +effectIt.effect( + "fails visibly without starting a fresh native conversation after an unknown resume binding", + () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue once", + unknownResumeBinding: true, + }); + yield* harness.start; + expect(harness.fallbackEnsure).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + const failure = harness.events.find( + (event) => event.type === "run.updated" && event.payload.status === "failed", + ); + expect(failure).toBeDefined(); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index a3e3b122c..4db80adfe 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -37,6 +37,10 @@ import { import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; import { ProviderAdapterTurnStartError, + hasUnknownRuntimeBinding, + runtimeBinding, + identityForRequest, + requestedRuntimeIdentity, type ProviderAdapterV2Error, type ProviderAdapterV2HistoricalContext, type ProviderAdapterV2SessionRuntime, @@ -684,6 +688,8 @@ export const layer: Layer.Layer< return resumed.success; } + if (hasUnknownRuntimeBinding(resumed.failure)) + return yield* loadFromProvider(Effect.fail(resumed.failure)); yield* Effect.logWarning("Provider resume failed; attempting a fresh native session", { driver: session.driver, providerThreadId: providerThread.id, @@ -818,6 +824,10 @@ export const layer: Layer.Layer< }); const runningProviderThread: OrchestrationV2ProviderThread = { ...loadedProviderThread, + runtimeIdentity: identityForRequest( + requestedRuntimeIdentity(run.modelSelection, session.driver), + loadedProviderThread.runtimeIdentity, + ), contextUsage: handoffUsage, id: providerThread.id, driver: session.driver, @@ -930,7 +940,28 @@ export const layer: Layer.Layer< payload: runningRootNode, }, ]; + const runningBinding = + loadedProviderThread.runtimeIdentity?.runtimeGeneration === undefined + ? undefined + : runtimeBinding( + runningProviderThread, + loadedProviderThread.runtimeIdentity.runtimeGeneration, + ); const runningWrite = yield* eventSink.writeIfRunCurrent({ + runtimeIdentityRequest: runningProviderThread.runtimeIdentity!.requested, + ...(loadedProviderThread.runtimeIdentity === undefined + ? {} + : { + runtimeIdentityPreviousRequest: loadedProviderThread.runtimeIdentity.requested, + }), + ...(runningBinding === undefined + ? {} + : { + runtimeEvidence: { + ...runningBinding, + evidenceRevision: loadedProviderThread.runtimeIdentity?.evidenceRevision, + }, + }), threadId: projection.thread.id, runId: run.id, activeAttemptId: attempt.id, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 3d6dca615..23ef08b49 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -16,11 +16,13 @@ import { type OrchestrationV2RunAttempt, type OrchestrationV2Subagent, type OrchestrationV2TurnItem, + ProjectId, ProviderDriverKind, ProviderInstanceId, ProviderSessionId, ProviderThreadId, ProviderTurnId, + type ProviderRuntimeEvidenceCapture, RunAttemptId, RunId, ServerSettingsError, @@ -36,6 +38,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as EventStore from "./EventStore.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -49,6 +54,7 @@ import { type ProviderAdapterV2Error, type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, + unobservedRuntimeIdentity, } from "./ProviderAdapter.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; @@ -4016,3 +4022,415 @@ it.effect("releases ingestion after idle subagent rows and items settle", () => ]); }), ); + +it("leaves runtime identity observation ownership and route state to the session manager", () => { + const identity: RunExecutionService.ProviderEventRouteIdentity = { + threadId: ThreadId.make("identity-manager-owner"), + runId: RunId.make("identity-run"), + attemptId: RunAttemptId.make("identity-attempt"), + providerThreadId: ProviderThreadId.make("identity-provider-thread"), + }; + const state = RunExecutionService.makeProviderEventRoutingState({ + identity, + providerTurnId: null, + }); + const event: ProviderAdapterV2Event = { + type: "runtime_identity.observed", + driver, + binding: { + threadId: identity.threadId, + providerThreadId: identity.providerThreadId, + providerSessionId: ProviderSessionId.make("identity-session"), + providerInstanceId: ProviderInstanceId.make("codex"), + driver, + nativeThreadId: "identity-native", + runtimeGeneration: "actual-process", + }, + requested: { + providerInstanceId: ProviderInstanceId.make("codex"), + providerDriver: driver, + model: "requested", + serviceTier: null, + }, + observed: unobservedRuntimeIdentity(), + }; + const [accepted, next] = RunExecutionService.routeProviderEvent(event, identity, state); + assert.isFalse(accepted); + assert.strictEqual(next, state); + assert.deepEqual([...next.ownedProviderTurnIds], []); +}); + +const runtimeTerminalStores = Layer.merge(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provide(SqlitePersistenceMemory), +); +const runtimeTerminalTestLayer = Layer.mergeAll( + runtimeTerminalStores, + SqlitePersistenceMemory, + EventSink.layer.pipe(Layer.provide(Layer.merge(runtimeTerminalStores, SqlitePersistenceMemory))), +); + +it.effect.each([ + { mode: "terminal", replaced: false }, + { mode: "terminal", replaced: true }, + { mode: "superseded hard Stop", replaced: false }, + { mode: "superseded hard Stop", replaced: true }, +] as const)( + "guards $mode against a generation replaced before commit: $replaced", + ({ mode, replaced }) => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const projection = yield* ProjectionStore.ProjectionStoreV2; + const eventStore = yield* EventStore.EventStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-runtime-terminal"); + const providerThreadId = ProviderThreadId.make("provider-thread-runtime-terminal"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const providerSessionId = ProviderSessionId.make("session-runtime-terminal"); + const runId = RunId.make("run-runtime-terminal"); + const attemptId = RunAttemptId.make("attempt-runtime-terminal"); + const rootNodeId = NodeId.make("root-runtime-terminal"); + const providerTurnId = ProviderTurnId.make("turn-runtime-terminal"); + const modelSelection = { instanceId: providerInstanceId, model: "requested-model" }; + const app: OrchestrationV2AppThread = { + id: threadId, + projectId: ProjectId.make("project-runtime-terminal"), + title: "Runtime terminal", + createdBy: "user", + creationSource: "web", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThreadId, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId, + providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-runtime-terminal", strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const run: OrchestrationV2Run = { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make("message-runtime-terminal"), + rootNodeId, + activeAttemptId: attemptId, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + const attempt: OrchestrationV2RunAttempt = { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId, + providerInstanceId, + providerThreadId, + providerTurnId, + reason: "initial", + status: "running", + startedAt: now, + completedAt: null, + }; + const checkpointScope: OrchestrationV2CheckpointScope = { + id: CheckpointScopeId.make("checkpoint-scope-runtime-terminal"), + threadId, + runId, + nodeId: rootNodeId, + parentScopeId: null, + providerThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/synthetic/runtime-terminal", + createdAt: now, + }; + const rootNode: OrchestrationV2ExecutionNode = { + id: rootNodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: checkpointScope.id, + startedAt: now, + completedAt: null, + }; + yield* sink.write({ + events: [ + { + id: EventId.make("terminal-seed-app"), + type: "thread.created", + threadId, + occurredAt: now, + payload: app, + }, + { + id: EventId.make("terminal-seed-owner"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: providerThread, + }, + { + id: EventId.make("terminal-seed-run"), + type: "run.created", + threadId, + occurredAt: now, + payload: run, + }, + { + id: EventId.make("terminal-seed-attempt"), + type: "run-attempt.updated", + threadId, + occurredAt: now, + payload: attempt, + }, + { + id: EventId.make("terminal-seed-node"), + type: "node.updated", + threadId, + occurredAt: now, + payload: rootNode, + }, + ], + }); + const requested = { + providerInstanceId, + providerDriver: driver, + model: modelSelection.model, + serviceTier: null, + }; + const observed = { + ...unobservedRuntimeIdentity(), + model: { + status: "observed" as const, + value: "native-model", + sourceEvent: "codex.thread/open", + }, + }; + const bound = { + ...providerThread, + runtimeIdentity: { runtimeGeneration: "native-producer", requested, observed }, + }; + const capture: ProviderRuntimeEvidenceCapture = { + threadId, + providerThreadId, + providerSessionId, + providerInstanceId, + driver, + nativeThreadId: "native-runtime-terminal", + runtimeGeneration: "native-producer", + evidenceRevision: 1, + }; + const terminal: Extract = { + type: "turn.terminal", + driver, + providerThreadId, + providerTurnId, + runOrdinal: 1, + status: mode === "terminal" ? "completed" : "interrupted", + failure: null, + threadDisposition: "reusable", + runtimeEvidence: capture, + }; + const offerTerminal = yield* Deferred.make(); + const commitReady = + yield* Deferred.make[0]>(); + const commit = yield* Deferred.make(); + const finished = yield* Deferred.make(); + const observedSink = EventSink.EventSinkV2.of({ + ...sink, + writeWithEffects: (input) => + Effect.gen(function* () { + yield* Deferred.succeed(commitReady, input); + yield* Deferred.await(commit); + return yield* sink.writeWithEffects(input); + }).pipe(Effect.ensuring(Deferred.succeed(finished, undefined))), + }); + const executionLayer = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(EventSink.EventSinkV2, observedSink), + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => Effect.void, + }), + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + IdAllocator.layer, + ServerSettings.layerTest(), + ), + ), + ); + yield* Effect.gen(function* () { + const execution = yield* RunExecutionService.RunExecutionServiceV2; + yield* execution.startRootRun({ + commandId: CommandId.make("command-runtime-terminal"), + appThread: app, + providerSessionId, + session: { + events: Stream.fromEffect(Deferred.await(offerTerminal)).pipe( + Stream.map(() => terminal), + ), + startTurn: () => + Effect.gen(function* () { + yield* sink.write({ + runtimeIdentityBoundary: { expectedGeneration: null }, + events: [ + { + id: EventId.make("terminal-late-native-binding"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: bound, + }, + ], + }); + yield* sink.write({ + runtimeEvidence: capture, + runtimeIdentityObservation: requested, + events: [ + { + id: EventId.make("terminal-native-observation"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: bound, + }, + ], + }); + yield* Deferred.succeed(offerTerminal, undefined); + }), + } as unknown as ProviderAdapterV2SessionRuntime, + run, + rootNode, + checkpointScope, + providerThread, + attempt, + attemptId, + providerTurnOrdinal: 1, + shouldFinalizeRun: () => Effect.succeed(mode === "terminal"), + hasUnpairedRunInterruptRequest: () => Effect.succeed(true), + message: { + messageId: run.userMessageId, + text: "One prompt", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + const pending = yield* Deferred.await(commitReady); + assert.strictEqual( + pending.runtimeEvidence, + capture, + "the native terminal capture must be passed unchanged", + ); + const before = (yield* projection.getThreadProjection(threadId)).providerThreads[0]!; + assert.equal(before.runtimeIdentity?.evidenceRevision, 2); + if (replaced) + yield* sink.write({ + runtimeIdentityBoundary: { expectedGeneration: "native-producer" }, + events: [ + { + id: EventId.make("terminal-replacement-before-commit"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...before, + runtimeIdentity: { + ...before.runtimeIdentity!, + runtimeGeneration: "replacement-producer", + observed: { + ...observed, + model: { + status: "observed", + value: "replacement-model", + sourceEvent: "codex.thread/open", + }, + }, + }, + }, + }, + ], + }); + const currentIdentity = (yield* projection.getThreadProjection(threadId)).providerThreads[0] + ?.runtimeIdentity; + yield* Deferred.succeed(commit, undefined); + yield* Deferred.await(finished); + const after = yield* projection.getThreadProjection(threadId); + assert.deepEqual(after.providerThreads[0]?.runtimeIdentity, currentIdentity); + assert.equal( + after.providerThreads[0]?.status, + mode === "terminal" && !replaced ? "idle" : "active", + ); + assert.equal( + after.runs[0]?.status, + mode === "terminal" && !replaced ? "waiting" : "running", + ); + assert.equal( + after.attempts[0]?.status, + mode === "terminal" && !replaced ? "completed" : "running", + ); + const recorded = yield* eventStore.read({ threadId }).pipe(Stream.runCollect); + const terminalWrites = recorded.filter(({ event }) => + mode === "terminal" + ? event.type === "run.updated" + : event.type === "turn-item.updated" && event.payload.type === "run_interrupt_result", + ); + assert.lengthOf(terminalWrites, replaced ? 0 : 1); + const checkpointEffects = yield* sql<{ readonly effect_type: string }>` + SELECT effect_type FROM orchestration_v2_effect_outbox WHERE effect_id = ${`effect:checkpoint.capture:${runId}`} + `; + assert.deepEqual( + checkpointEffects, + mode === "terminal" && !replaced ? [{ effect_type: "checkpoint.capture" }] : [], + ); + if (replaced) + assert.equal( + after.providerThreads[0]?.runtimeIdentity?.runtimeGeneration, + "replacement-producer", + ); + else assert.deepEqual(after.providerThreads[0]?.runtimeIdentity?.observed, observed); + }).pipe(Effect.ensuring(Deferred.succeed(commit, undefined)), Effect.provide(executionLayer)); + }).pipe(Effect.provide(runtimeTerminalTestLayer)), +); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 6aff5da24..de88bcb09 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -369,6 +369,8 @@ export function routeProviderEvent( }); switch (event.type) { + case "runtime_identity.observed": + return [false, state]; case "provider_session.updated": // The session manager persists process-wide status once for every // attached app thread before broadcasting the adapter event. @@ -590,6 +592,9 @@ export const layer: Layer.Layer< : yield* input.hasUnpairedRunInterruptRequest(); if (hasUnpairedRequest) { yield* eventSink.writeWithEffects({ + ...(input.terminal.runtimeEvidence === undefined + ? {} + : { runtimeEvidence: input.terminal.runtimeEvidence }), effects: [], events: [ { @@ -665,6 +670,9 @@ export const layer: Layer.Layer< // the next message. The capture is enqueued with these terminal events, // ahead of any later run's start on this thread's effect lane. const finalization = { + ...(input.terminal.runtimeEvidence === undefined + ? {} + : { runtimeEvidence: input.terminal.runtimeEvidence }), effects: input.terminal.status === "completed" || input.terminal.status === "interrupted" || @@ -777,6 +785,9 @@ export const layer: Layer.Layer< } satisfies Parameters[0]; if (input.writeIfRunCurrent !== undefined) { const result = yield* eventSink.writeIfRunCurrent({ + ...(input.terminal.runtimeEvidence === undefined + ? {} + : { runtimeEvidence: input.terminal.runtimeEvidence }), threadId: input.run.threadId, runId: input.run.id, activeAttemptId: input.writeIfRunCurrent.activeAttemptId, diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index a2e2c87c3..1f271c3e9 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -13,12 +13,14 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; import * as IdAllocator from "../IdAllocator.ts"; +import { unobservedRuntimeIdentity } from "../ProviderAdapter.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT, @@ -265,6 +267,16 @@ describe("orchestration V2 thread fork", () => { const targetProjection = result.projections.get(materialized.targetThreadId); assert.isDefined(sourceProjection); assert.isDefined(targetProjection); + assert.isString(sourceProjection.providerThreads[0]?.runtimeIdentity?.runtimeGeneration); + assert.isString(targetProjection.providerThreads[0]?.runtimeIdentity?.runtimeGeneration); + assert.notEqual( + targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId, + sourceProjection.providerThreads[0]?.nativeThreadRef?.nativeId, + ); + assert.equal( + targetProjection.providerThreads[0]?.runtimeIdentity?.requested.providerInstanceId, + targetProjection.thread.modelSelection.instanceId, + ); assert.equal(targetProjection.thread.lineage.parentThreadId, materialized.sourceThreadId); assert.equal(targetProjection.thread.lineage.relationshipToParent, "fork"); assert.lengthOf(targetProjection.providerSessions, 1); @@ -430,6 +442,98 @@ describe("orchestration V2 thread fork", () => { const targetProjection = result.projections.get(materialized.targetThreadId); assert.isDefined(sourceProjection); assert.isDefined(targetProjection); + const nativeInitModels = transcript.entries.flatMap((entry) => + entry.type === "emit_inbound" && + Predicate.isObject(entry.frame) && + entry.frame.type === "system" && + entry.frame.subtype === "init" && + typeof entry.frame.model === "string" + ? [entry.frame.model] + : [], + ); + assert.lengthOf( + nativeInitModels, + 2, + "the unchanged replay reports source and target query init models", + ); + const sourceIdentityEvents = result.domainEvents.flatMap((event, index) => + event.type === "provider-thread.updated" && + event.threadId === materialized.sourceThreadId && + event.payload.id === sourceProjection.providerThreads[0]?.id + ? [{ index, providerThread: event.payload }] + : [], + ); + const sourceObservation = sourceIdentityEvents.find( + ({ providerThread }) => + providerThread.runtimeIdentity?.runtimeGeneration !== undefined && + providerThread.runtimeIdentity.observed.model.status === "observed", + ); + assert.isDefined( + sourceObservation, + "the source query must bind native model evidence before fork", + ); + if (sourceObservation === undefined) return assert.fail("Missing source query observation"); + assert.isString(sourceObservation.providerThread.runtimeIdentity?.runtimeGeneration); + assert.equal( + sourceObservation.providerThread.runtimeIdentity?.observed.model.status, + "observed", + ); + if ( + sourceObservation.providerThread.runtimeIdentity?.observed.model.status === "observed" + ) { + assert.equal( + sourceObservation.providerThread.runtimeIdentity.observed.model.sourceEvent, + "claude.system:init", + ); + assert.equal( + sourceObservation.providerThread.runtimeIdentity.observed.model.value, + nativeInitModels[0], + ); + } + const abandonment = sourceIdentityEvents.find( + ({ index, providerThread }) => + index > sourceObservation.index && + providerThread.runtimeIdentity !== undefined && + providerThread.runtimeIdentity.runtimeGeneration === undefined, + ); + assert.isDefined(abandonment, "fork must invalidate the closed source query"); + if (abandonment === undefined) return assert.fail("Missing source query abandonment"); + const targetBindingIndex = result.domainEvents.findIndex( + (event) => + event.type === "provider-thread.updated" && + event.threadId === materialized.targetThreadId && + event.payload.id === targetProjection.providerThreads[0]?.id && + event.payload.runtimeIdentity?.runtimeGeneration !== undefined, + ); + assert.isAbove( + targetBindingIndex, + abandonment.index, + "the source query closes before the target query binds", + ); + const finalSourceIdentity = sourceProjection.providerThreads[0]?.runtimeIdentity; + assert.isDefined(finalSourceIdentity); + assert.isUndefined(finalSourceIdentity?.runtimeGeneration); + assert.deepEqual( + finalSourceIdentity?.requested, + sourceObservation.providerThread.runtimeIdentity?.requested, + ); + assert.deepEqual(finalSourceIdentity?.observed, unobservedRuntimeIdentity()); + const targetObservedModel = + targetProjection.providerThreads[0]?.runtimeIdentity?.observed.model; + assert.equal(targetObservedModel?.status, "observed"); + if (targetObservedModel?.status === "observed") { + assert.equal(targetObservedModel.sourceEvent, "claude.system:init"); + assert.equal(targetObservedModel.value, nativeInitModels[1]); + } + assert.isString(targetProjection.providerThreads[0]?.runtimeIdentity?.runtimeGeneration); + assert.notEqual( + targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId, + sourceProjection.providerThreads[0]?.nativeThreadRef?.nativeId, + ); + assert.equal( + targetProjection.providerThreads[0]?.runtimeIdentity?.requested.providerInstanceId, + targetProjection.thread.modelSelection.instanceId, + ); assert.equal( targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId, forkedNativeSessionId, diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index ad05cff37..c6e1b9fde 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runJonesMigrations } from "./JonesMigrationGuard.ts"; @@ -341,3 +342,44 @@ it.effect("an explicit upstream limit leaves absent and invalid Jones history un assert.deepStrictEqual(yield* sql`SELECT * FROM jones_sql_migrations`, before); }).pipe(Effect.provide(memory)), ); + +const encodePreexistingRuntimeIdentity = Schema.encodeSync( + Schema.fromJsonString(Schema.Struct({ runtimeGeneration: Schema.String })), +); + +it.effect("leaves bounded upstream replay untouched and migrates existing sessions as null", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 54 }); + const before = yield* sql<{ name: string }>`PRAGMA table_info(projection_thread_sessions)`; + assert.isFalse(before.some((column) => column.name === "runtime_identity_json")); + assert.deepEqual( + yield* sql`SELECT name FROM sqlite_master WHERE name = 'jones_sql_migrations'`, + [], + ); + yield* sql`INSERT INTO projection_thread_sessions + (thread_id, status, provider_name, runtime_mode, updated_at) + VALUES ('old-session', 'ready', 'codex', 'full-access', '2026-09-01T00:00:00.000Z')`; + yield* runMigrations(); + assert.deepEqual(yield* sql`SELECT runtime_identity_json FROM projection_thread_sessions`, [ + { runtime_identity_json: null }, + ]); + }).pipe(Effect.provide(memory)), +); + +it.effect("preserves preexisting identity JSON when the column predates the fork ledger", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 54 }); + yield* Jones002; + const identity = encodePreexistingRuntimeIdentity({ runtimeGeneration: "preexisting-runtime" }); + yield* sql`INSERT INTO projection_thread_sessions + (thread_id, status, provider_name, runtime_mode, updated_at, runtime_identity_json) + VALUES ('existing-identity', 'ready', 'codex', 'full-access', '2026-09-01T00:00:00.000Z', ${identity})`; + yield* runMigrations(); + yield* Jones002; + assert.deepEqual(yield* sql`SELECT runtime_identity_json FROM projection_thread_sessions`, [ + { runtime_identity_json: identity }, + ]); + }).pipe(Effect.provide(memory)), +); diff --git a/docs/internals/provider-runtime-identity.md b/docs/internals/provider-runtime-identity.md new file mode 100644 index 000000000..b252e0070 --- /dev/null +++ b/docs/internals/provider-runtime-identity.md @@ -0,0 +1,47 @@ +# Provider runtime identity + +A requested model is routing intent. An observed model describes native evidence +from the process that serves a particular conversation. Keep them separate even +when their values happen to match. Neither provider settings, authentication +metadata nor a model catalog establishes observed backend, model, account or tier. +The [identity contract](../../packages/contracts/src/providerRuntimeIdentity.ts) +uses unknown and unavailable states so missing evidence stays visible. + +Identity belongs to a provider thread, not a shared session. One Codex app-server +can serve native conversations with different models. Its process generation is +reserved before launch and captured by its callbacks; a logical session ID or an +idle-timer generation cannot substitute for that incarnation. Typed Codex +thread-open responses provide model/backend/tier evidence. Native reroute +notifications update only observed model. Claude SDK init provides model evidence only. Neither boundary safely +binds an account to the process. + +[Session management](../../apps/server/src/orchestration-v2/ProviderSessionManager.ts) +publishes the successful binding boundary before releasing observations. A failed +or interrupted candidate cannot become current. Replacement invalidates the old +issuer. Codex token rotation resumes the same native cursor with the current +request before sending the next prompt. It refuses replacement while shared +active or background work remains. A capacity retry remains attached to its original prompt and +producer; runtime drift cancels it rather than replaying the prompt. + +Claude rewind closes the old query and preserves or resets its continuation. +The replacement generation exists only when the next send actually launches a +query. Rollback itself is not a replacement-process observation. Historical +identity in JSON likewise records past evidence; replay never activates a producer. + +The [event sink](../../apps/server/src/orchestration-v2/EventSink.ts) validates the +native binding inside the same transaction as the projection write. Evidence +revision can advance within an incarnation. With a pinned generation, later +revisions are valid only for the same application thread, provider thread, session, +driver, instance and native conversation; revision regression remains invalid. +Without a generation, a captured revision must match exactly. Late snapshots +preserve newer requested configuration and observations. A start captured before an +observation-only revision advance may still commit when the exact previous +instance, driver, model and explicit tier remain current. A requested-configuration +change rejects that stale writer even when the process generation is unchanged. + +Full and detail views retain each provider thread's own identity. Shell identity +comes only from the exact active provider thread of that application thread. Old snapshots may omit identity, and legacy Jones migration002 remains compatibility +storage rather than evidence of a live V2 runtime. Explicit service-tier options +are recorded as requested; normalization of the fast-mode alias remains deferred. +A native launch followed by binding-publication failure remains an unknown effect: +fail visibly and stop replay. Automatic recovery of that boundary is deferred. diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 5b319eb10..30f5e0083 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -86,6 +86,7 @@ function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolea } export interface EnvironmentThreadShell { + readonly runtimeIdentity?: OrchestrationV2ThreadShell["runtimeIdentity"]; readonly environmentId: EnvironmentId; readonly id: ThreadId; readonly projectId: ProjectId; @@ -240,6 +241,7 @@ export function presentThreadShell( lineage: thread.lineage, forkedFrom: thread.forkedFrom, activeProviderThreadId: thread.activeProviderThreadId, + ...(thread.runtimeIdentity === undefined ? {} : { runtimeIdentity: thread.runtimeIdentity }), latestRun, runtime: shellRuntime(thread), latestUserMessageAt: nullableIso(thread.latestUserMessageAt), diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 0009f1a7a..ee51ea5ab 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,6 +1,8 @@ import { EnvironmentId, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ThreadId, type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; @@ -8,6 +10,7 @@ import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it } from "vite-plus/test"; +import { presentThreadShell } from "./models.ts"; import { PrimaryConnectionTarget } from "../connection/model.ts"; import { v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; @@ -199,3 +202,29 @@ describe("v2 thread shell lists", () => { } }); }); + +it("retains optional server-selected runtime identity without activating replayed generations", () => { + const identity = { + runtimeGeneration: "historical-native-process", + evidenceRevision: 4, + requested: { + providerInstanceId: ProviderInstanceId.make("codex"), + providerDriver: ProviderDriverKind.make("codex"), + model: "requested", + serviceTier: null, + }, + observed: { + backend: { status: "unknown" as const }, + model: { status: "observed" as const, value: "native", sourceEvent: "codex.thread/open" }, + account: { status: "unavailable" as const, reason: "Not reported." }, + serviceTier: { status: "unavailable" as const, reason: "Not reported." }, + }, + }; + const presented = presentThreadShell(environmentId, { + ...v2ThreadShell, + runtimeIdentity: identity, + }); + expect(presented.runtimeIdentity).toEqual(identity); + expect(presented.modelSelection).toEqual(v2ThreadShell.modelSelection); + expect(presentThreadShell(environmentId, v2ThreadShell)).not.toHaveProperty("runtimeIdentity"); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8690bb1b2..c44eea06a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,6 +17,7 @@ export * from "./provider.ts"; export * from "./providerInstance.ts"; export * from "./providerSetup.ts"; export * from "./providerRuntime.ts"; +export * from "./providerRuntimeIdentity.ts"; export * from "./providerUsageLimits.ts"; export * from "./usageLimitSourceId.ts"; export * from "./providerPolicy.ts"; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 601597fb5..d77b63070 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -14,6 +14,7 @@ import { NonNegativeInt, ProjectId, ProviderInstanceId, + ProviderDriverKind, ProviderReplayTranscript, ProviderThreadId, RunId, @@ -971,6 +972,32 @@ describe("orchestration V2 contracts", () => { expect(providerThread.pendingBackgroundTasks).toEqual([]); expect(providerThread.contextUsage).toBeNull(); expect(providerThread.nativeMetadata).toBeNull(); + expect(providerThread.runtimeIdentity).toBeUndefined(); + const identity = { + runtimeGeneration: "native-query-7", + evidenceRevision: 3, + requested: { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerDriver: ProviderDriverKind.make("claudeAgent"), + model: "requested", + serviceTier: null, + }, + observed: { + backend: { status: "unavailable" as const, reason: "Not reported." }, + model: { + status: "observed" as const, + value: "native-model", + sourceEvent: "claude.system:init", + }, + account: { status: "unavailable" as const, reason: "Not bound." }, + serviceTier: { status: "unavailable" as const, reason: "Not reported." }, + }, + }; + expect( + decodeOrchestrationV2ProviderThreadJson( + encodeOrchestrationV2ProviderThreadJson({ ...providerThread, runtimeIdentity: identity }), + ).runtimeIdentity, + ).toEqual(identity); const runtimeThread = decodeOrchestrationV2ProviderThread({ id: "provider-thread-2", @@ -992,6 +1019,7 @@ describe("orchestration V2 contracts", () => { expect(runtimeThread.pendingBackgroundTasks).toEqual([]); expect(runtimeThread.contextUsage).toBeNull(); expect(runtimeThread.nativeMetadata).toBeNull(); + expect(runtimeThread.runtimeIdentity).toBeUndefined(); }); it("decodes historical thread shell JSON without pendingBackgroundTasks as empty roster", () => { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 220fec2b1..d69aac14f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1,3 +1,4 @@ +import { RuntimeIdentityAttestation } from "./providerRuntimeIdentity.ts"; import { OrchestrationMessageContext } from "./composerContext.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -833,6 +834,7 @@ export type OrchestrationV2ProviderThreadNativeMetadata = typeof OrchestrationV2ProviderThreadNativeMetadata.Type; export const OrchestrationV2ProviderThread = Schema.Struct({ + runtimeIdentity: Schema.optional(RuntimeIdentityAttestation), id: ProviderThreadId, driver: ProviderDriverKind, providerInstanceId: ProviderInstanceId, @@ -1712,6 +1714,7 @@ export type OrchestrationV2LatestVisibleMessageSummary = typeof OrchestrationV2LatestVisibleMessageSummary.Type; export const OrchestrationV2ThreadShell = Schema.Struct({ + runtimeIdentity: Schema.optional(RuntimeIdentityAttestation), ...OrchestrationV2CreationFields, id: ThreadId, projectId: ProjectId, diff --git a/packages/contracts/src/providerRuntimeIdentity.test.ts b/packages/contracts/src/providerRuntimeIdentity.test.ts new file mode 100644 index 000000000..8b1e0d4ad --- /dev/null +++ b/packages/contracts/src/providerRuntimeIdentity.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { + RuntimeIdentityAttestation, + RuntimeIdentityObservation, + ProviderRuntimeBinding, +} from "./providerRuntimeIdentity.ts"; + +const decodeIdentity = Schema.decodeUnknownSync(RuntimeIdentityAttestation); +const decodeObservation = Schema.decodeUnknownSync(RuntimeIdentityObservation); +const decodeBinding = Schema.decodeUnknownSync(ProviderRuntimeBinding); + +describe("provider runtime identity", () => { + it("keeps requested routing separate from observed runtime identity", () => { + const identity = decodeIdentity({ + runtimeGeneration: "actual-process-1", + evidenceRevision: 4, + requested: { + providerInstanceId: "codex", + providerDriver: "codex", + model: "requested", + serviceTier: "priority", + }, + observed: { + backend: { status: "observed", value: "native-backend", sourceEvent: "codex.thread/open" }, + model: { status: "unknown" }, + account: { status: "unavailable", reason: "The native protocol does not bind an account." }, + serviceTier: { status: "unavailable", reason: "No native tier was reported." }, + }, + }); + expect(identity.requested.model).toBe("requested"); + expect(identity.observed.model).toEqual({ status: "unknown" }); + expect(identity.observed.account.status).toBe("unavailable"); + expect(identity.observed.serviceTier.status).toBe("unavailable"); + }); + + it.each([ + { status: "observed", value: "", sourceEvent: "native" }, + { status: "observed", value: "native", sourceEvent: " " }, + { status: "unavailable", reason: " " }, + ])("rejects unusable native evidence: %j", (observation) => { + expect(() => decodeObservation(observation)).toThrow(); + }); + + it("requires a producer generation for an identity-bearing native binding", () => { + const binding = { + threadId: "app", + providerThreadId: "provider-thread", + providerSessionId: "logical-session", + providerInstanceId: "codex", + driver: "codex", + nativeThreadId: "native-thread", + }; + expect(() => decodeBinding(binding)).toThrow(); + expect( + decodeBinding({ ...binding, runtimeGeneration: "actual-process" }).runtimeGeneration, + ).toBe("actual-process"); + }); +}); diff --git a/packages/contracts/src/providerRuntimeIdentity.ts b/packages/contracts/src/providerRuntimeIdentity.ts new file mode 100644 index 000000000..2f34a19cd --- /dev/null +++ b/packages/contracts/src/providerRuntimeIdentity.ts @@ -0,0 +1,64 @@ +import * as Schema from "effect/Schema"; +import { + NonNegativeInt, + ProviderSessionId, + ProviderThreadId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +/** Missing native evidence must never be filled from routing or authentication metadata. */ +export const RuntimeIdentityObservation = Schema.Union([ + Schema.Struct({ status: Schema.Literal("unknown") }), + Schema.Struct({ status: Schema.Literal("unavailable"), reason: TrimmedNonEmptyString }), + Schema.Struct({ + status: Schema.Literal("observed"), + value: TrimmedNonEmptyString, + sourceEvent: TrimmedNonEmptyString, + }), +]); +export type RuntimeIdentityObservation = typeof RuntimeIdentityObservation.Type; + +export const ObservedRuntimeIdentity = Schema.Struct({ + backend: RuntimeIdentityObservation, + model: RuntimeIdentityObservation, + account: RuntimeIdentityObservation, + serviceTier: RuntimeIdentityObservation, +}); +export type ObservedRuntimeIdentity = typeof ObservedRuntimeIdentity.Type; + +export const RequestedRuntimeIdentity = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + providerDriver: ProviderDriverKind, + model: TrimmedNonEmptyString, + serviceTier: Schema.NullOr(TrimmedNonEmptyString), +}); +export type RequestedRuntimeIdentity = typeof RequestedRuntimeIdentity.Type; + +export const RuntimeIdentityAttestation = Schema.Struct({ + runtimeGeneration: Schema.optional(TrimmedNonEmptyString), + evidenceRevision: Schema.optional(NonNegativeInt), + requested: RequestedRuntimeIdentity, + observed: ObservedRuntimeIdentity, +}); +export type RuntimeIdentityAttestation = typeof RuntimeIdentityAttestation.Type; + +/** A process incarnation and the native conversation it actually owns are independent IDs. */ +export const ProviderRuntimeBinding = Schema.Struct({ + threadId: ThreadId, + providerThreadId: ProviderThreadId, + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + driver: ProviderDriverKind, + nativeThreadId: TrimmedNonEmptyString, + runtimeGeneration: TrimmedNonEmptyString, +}); +export type ProviderRuntimeBinding = typeof ProviderRuntimeBinding.Type; + +export const ProviderRuntimeEvidenceCapture = Schema.Struct({ + ...ProviderRuntimeBinding.fields, + runtimeGeneration: Schema.optional(TrimmedNonEmptyString), + evidenceRevision: Schema.optional(NonNegativeInt), +}); +export type ProviderRuntimeEvidenceCapture = typeof ProviderRuntimeEvidenceCapture.Type; From b6f865e9e07e6c2e12aba3ebfb8418b2b3147e03 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 05:46:53 +0200 Subject: [PATCH 21/59] feat(desktop): restore isolated UI evidence harness [L30] --- .agents/skills/capture-ui-evidence/SKILL.md | 35 ++ .../references/runner-output-and-limits.md | 57 ++++ .agents/skills/test-t3-app/SKILL.md | 15 +- .agents/skills/test-t3-mobile/SKILL.md | 7 +- .github/pull_request_template.md | 8 +- apps/desktop/scripts/ui-evidence.mjs | 250 ++++++++++++++ apps/desktop/scripts/ui-evidence/doctor.mjs | 128 +++++++ .../desktop/scripts/ui-evidence/lifecycle.mjs | 155 +++++++++ .../scripts/ui-evidence/lifecycle.test.mjs | 108 ++++++ apps/desktop/scripts/ui-evidence/manifest.mjs | 109 ++++++ .../scripts/ui-evidence/manifest.test.mjs | 77 +++++ apps/desktop/scripts/ui-evidence/probe.mjs | 58 ++++ .../scripts/ui-evidence/provenance.mjs | 202 +++++++++++ .../scripts/ui-evidence/provenance.test.mjs | 97 ++++++ apps/desktop/scripts/ui-evidence/runner.mjs | 255 ++++++++++++++ apps/desktop/scripts/ui-evidence/runtime.mjs | 279 +++++++++++++++ .../scripts/ui-evidence/runtime.test.mjs | 33 ++ apps/desktop/scripts/ui-evidence/sandbox.mjs | 191 +++++++++++ .../scripts/ui-evidence/sandbox.test.mjs | 62 ++++ .../scripts/ui-evidence/scenario-api.mjs | 322 ++++++++++++++++++ .../scripts/ui-evidence/scenario-api.test.mjs | 183 ++++++++++ .../scripts/ui-evidence/scenarios/example.mjs | 11 + .../ui-evidence/scenarios/sidebar-rename.mjs | 88 +++++ knip.jsonc | 5 + package.json | 2 + 25 files changed, 2732 insertions(+), 5 deletions(-) create mode 100644 .agents/skills/capture-ui-evidence/SKILL.md create mode 100644 .agents/skills/capture-ui-evidence/references/runner-output-and-limits.md create mode 100644 apps/desktop/scripts/ui-evidence.mjs create mode 100644 apps/desktop/scripts/ui-evidence/doctor.mjs create mode 100644 apps/desktop/scripts/ui-evidence/lifecycle.mjs create mode 100644 apps/desktop/scripts/ui-evidence/lifecycle.test.mjs create mode 100644 apps/desktop/scripts/ui-evidence/manifest.mjs create mode 100644 apps/desktop/scripts/ui-evidence/manifest.test.mjs create mode 100644 apps/desktop/scripts/ui-evidence/probe.mjs create mode 100644 apps/desktop/scripts/ui-evidence/provenance.mjs create mode 100644 apps/desktop/scripts/ui-evidence/provenance.test.mjs create mode 100644 apps/desktop/scripts/ui-evidence/runner.mjs create mode 100644 apps/desktop/scripts/ui-evidence/runtime.mjs create mode 100644 apps/desktop/scripts/ui-evidence/runtime.test.mjs create mode 100644 apps/desktop/scripts/ui-evidence/sandbox.mjs create mode 100644 apps/desktop/scripts/ui-evidence/sandbox.test.mjs create mode 100644 apps/desktop/scripts/ui-evidence/scenario-api.mjs create mode 100644 apps/desktop/scripts/ui-evidence/scenario-api.test.mjs create mode 100644 apps/desktop/scripts/ui-evidence/scenarios/example.mjs create mode 100644 apps/desktop/scripts/ui-evidence/scenarios/sidebar-rename.mjs diff --git a/.agents/skills/capture-ui-evidence/SKILL.md b/.agents/skills/capture-ui-evidence/SKILL.md new file mode 100644 index 000000000..09cab32b8 --- /dev/null +++ b/.agents/skills/capture-ui-evidence/SKILL.md @@ -0,0 +1,35 @@ +--- +name: capture-ui-evidence +description: Use the standard Jones Code UI verification and evidence workflow for UI changes and screenshots. Route interactive shared-renderer checks to T3 Browser, native Linux desktop scenarios to the Electron harness after exact-revision qualification, and mobile checks to test-t3-mobile. +--- + +# Capture Jones Code UI evidence + +This is the default workflow for verifying user-visible UI changes and recording evidence. Choose the affected client: + +- Shared web/desktop renderer: [test-t3-app](../test-t3-app/SKILL.md) and T3's Browser panel for live inspection, clicks, typing, screenshots and iteration. +- Linux native desktop shell: the Electron runner below for repeatable screenshots and scripted interactions after exact-revision qualification. +- Native mobile: [test-t3-mobile](../test-t3-mobile/SKILL.md). + +Exercise the changed flow, assert its observable result, and check backend readback or reload persistence when the change depends on them. Use the same evidence format on every route: full candidate/comparison revisions (plus patch identity for dirty source), build correspondence, client/route, actual viewport/scale/theme, meaningful fixture, action and readback, captures, and coverage limits. The source/build distinction in [runner output and evidence limits](references/runner-output-and-limits.md) applies to Browser evidence too; a dev source OID alone is not an attestation of the served bundle. + +T3 Browser remains the shared-renderer route. An unavailable Preview does not authorize switching to a standalone browser or using Electron as a Browser fallback. + +The Linux Electron runner is a separate route for behavior that depends on the desktop shell. Require a recorded passing qualification for the exact harness revision; exploratory output alone leaves Electron-specific behavior unverified. This nightly source port does not qualify a host or adopt an installed wrapper. A separately installed host command must remain pinned to its qualified revision, with readback in `~/.local/state/jones-code-ui-evidence/qualification.json`; an older source qualification does not qualify this port. + +The runner launches existing built outputs; it does not build the app. On a host with the installed command, first run `jones-code-ui-evidence doctor`, then use `jones-code-ui-evidence run --source /absolute/candidate --build-receipt /absolute/candidate-receipt.json` with a scenario that exercises the changed flow. The default `sidebar-rename` is a qualification/smoke scenario; it does not verify an unrelated UI change. A custom scenario receives a Playwright page and Electron app for inspecting and interacting with the candidate inside the isolated run. + +From the Jones Code repository root, use: + +```sh +node apps/desktop/scripts/ui-evidence.mjs doctor [--source DIR] +node apps/desktop/scripts/ui-evidence.mjs setup [--source DIR] +node apps/desktop/scripts/ui-evidence.mjs run [--source DIR] [--scenario sidebar-rename|/absolute/path/scenario.mjs] [--size 1280x800] [--scale 1] [--theme system|light|dark] [--comparison OID] [--build-receipt FILE] [--out DIR] +node apps/desktop/scripts/ui-evidence.mjs cleanup --run +``` + +The repository also exposes the CLI and its bounded test group as package scripts `ui:evidence` and `test:ui-evidence`, invoked through the existing `vp run ", + transcript_provider: "local", + language: "en", + edited: false, + created_at: "2026-10-02T12:00:00Z", + updated_at: "2026-10-02T12:00:00Z", + due_at: "2026-10-02T12:03:00Z", + remaining_ms: 180000, + expires_at: "2026-10-03T12:00:00Z", + command_id: null, + command_status: null, + reason: null, + server_now: "2026-10-02T12:00:00Z", + ...overrides, + }); +} +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("voice review acknowledged actions", () => { + it("does not enable editing before the acknowledgement; save and send uses the saved revision and literal text", async () => { + const begin = deferred(); + const mutate = vi + .fn() + .mockImplementationOnce(() => begin.promise) + .mockResolvedValueOnce({ + draft: draft({ revision: 3, state: "paused", text: "edited", edited: true }), + edit_handle: null, + }) + .mockResolvedValueOnce({ + draft: draft({ revision: 4, state: "released", text: null }), + edit_handle: null, + }); + const actions = new VoiceReviewActions(draft(), { mutate, get: vi.fn() }); + const pending = actions.act("edit-begin"); + expect(actions.editHandle).toBeNull(); + expect(actions.busy).toBe(true); + begin.resolve({ draft: draft({ revision: 2, state: "editing" }), edit_handle: "opaque" }); + await pending; + actions.setText("edited"); + await actions.act("send-now"); + expect(mutate).toHaveBeenCalledTimes(1); + await actions.act("edit-save", true); + expect(mutate.mock.calls[1]).toEqual([ + "capture-1", + "edit-save", + { expected_revision: 2, text: "edited", edit_handle: "opaque" }, + ]); + expect(mutate.mock.calls[2]).toEqual(["capture-1", "send-now", { expected_revision: 3 }]); + expect(actions.draft.state).toBe("released"); + expect(actions.editHandle).toBeNull(); + }); + + it("preserves unsaved text on conflict and never sends when save was not acknowledged", async () => { + const mutate = vi + .fn() + .mockResolvedValueOnce({ + draft: draft({ revision: 2, state: "editing" }), + edit_handle: "opaque", + }) + .mockRejectedValueOnce({ _tag: "VoiceReviewConflictError" }); + const get = vi + .fn() + .mockResolvedValue(draft({ revision: 3, state: "editing", text: "server text" })); + const actions = new VoiceReviewActions(draft(), { mutate, get }); + await actions.act("edit-begin"); + actions.setText("unsaved local text"); + await actions.act("edit-save", true); + actions.receive(draft({ revision: 3, state: "editing", text: "server text" })); + expect(actions.text).toBe("unsaved local text"); + expect(actions.draft.revision).toBe(3); + expect(mutate).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenCalledWith("capture-1"); + }); + + it("observes ambiguous mutations once and gates new mutations until explicit reconciliation", async () => { + const mutate = vi.fn().mockRejectedValue(new Error("private upstream exception")); + const get = vi.fn().mockResolvedValue(draft({ state: "released", revision: 2, text: null })); + const actions = new VoiceReviewActions(draft(), { mutate, get }); + await actions.act("send-now"); + expect(actions.uncertain).toBe(true); + expect(actions.error).not.toContain("private"); + await actions.act("send-now"); + expect(mutate).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledTimes(1); + await actions.reconcile(); + expect(actions.uncertain).toBe(false); + }); + + it("keeps another prompt actionable while the first response is delayed", async () => { + const slow = deferred(); + const first = new VoiceReviewActions(draft(), { mutate: () => slow.promise, get: vi.fn() }); + const secondMutation = vi.fn().mockResolvedValue({ + draft: draft({ id: "capture-2", revision: 2, state: "deleted", text: null }), + edit_handle: null, + }); + const second = new VoiceReviewActions(draft({ id: "capture-2" }), { + mutate: secondMutation, + get: vi.fn(), + }); + const pending = first.act("pause"); + await second.act("delete"); + expect(second.draft.state).toBe("deleted"); + expect(first.busy).toBe(true); + slow.resolve({ draft: draft({ revision: 2, state: "paused" }), edit_handle: null }); + await pending; + }); + + it("save and cancel finish paused; a fresh component does not restore an edit handle or play", async () => { + for (const action of ["edit-save", "edit-cancel"] as const) { + const mutate = vi + .fn() + .mockResolvedValueOnce({ + draft: draft({ revision: 2, state: "editing" }), + edit_handle: "holder", + }) + .mockResolvedValueOnce({ + draft: draft({ revision: 3, state: "paused" }), + edit_handle: null, + }); + const actions = new VoiceReviewActions(draft(), { mutate, get: vi.fn() }); + await actions.act("edit-begin"); + await actions.act(action); + expect(actions.draft.state).toBe("paused"); + expect(actions.editHandle).toBeNull(); + } + const mutate = vi.fn(); + const remount = new VoiceReviewActions(draft({ state: "editing", revision: 2 }), { + mutate, + get: vi.fn(), + }); + expect(remount.editHandle).toBeNull(); + await remount.act("play"); + expect(mutate).not.toHaveBeenCalled(); + }); + + it("projects held countdowns without a mutation and freezes paused/editing projections", () => { + expect(remainingSeconds(draft(), 1500)).toBe(179); + expect(remainingSeconds(draft(), 300000)).toBe(0); + expect(remainingSeconds(draft({ state: "paused" }), 300000)).toBe(180); + expect(remainingSeconds(draft({ state: "editing" }), 300000)).toBe(180); + expect(voiceReviewError({ _tag: "VoiceReviewNotConfiguredError" })).toContain("not configured"); + expect(voiceReviewError({ _tag: "VoiceReviewNotFoundError" })).toContain("unavailable"); + }); +}); diff --git a/apps/web/src/components/voiceReview/voiceReviewActions.ts b/apps/web/src/components/voiceReview/voiceReviewActions.ts new file mode 100644 index 000000000..afbc5e553 --- /dev/null +++ b/apps/web/src/components/voiceReview/voiceReviewActions.ts @@ -0,0 +1,137 @@ +import type { + VoiceReviewAction, + VoiceReviewDraft, + VoiceReviewMutationPayload, + VoiceReviewMutationResult, +} from "@t3tools/contracts"; + +export interface VoiceReviewTransport { + mutate( + id: string, + action: VoiceReviewAction, + payload: VoiceReviewMutationPayload, + ): Promise; + get(id: string): Promise; +} + +export function voiceReviewError(error: unknown): string { + const tag = typeof error === "object" && error !== null && "_tag" in error ? error._tag : null; + switch (tag) { + case "VoiceReviewNotConfiguredError": + return "Voice review is not configured for this environment."; + case "VoiceReviewForbiddenError": + return "This session cannot review voice prompts."; + case "VoiceReviewNotFoundError": + return "Voice review is unavailable on this environment, or this prompt no longer exists."; + case "VoiceReviewConflictError": + return "This prompt changed. Your unsaved text is preserved; review its current state before acting again."; + default: + return "The result is uncertain. Check the current prompt state before taking another action."; + } +} + +export class VoiceReviewActions { + draft: VoiceReviewDraft; + text: string; + editHandle: string | null = null; + busy = false; + error: string | null = null; + uncertain = false; + observedAt = performance.now(); + private listeners = new Set<() => void>(); + private version = 0; + constructor( + draft: VoiceReviewDraft, + private transport: VoiceReviewTransport, + ) { + this.draft = draft; + this.observedAt = performance.now(); + this.text = draft.text ?? ""; + } + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + snapshot = () => this.version; + private notify() { + this.version++; + this.listeners.forEach((listener) => listener()); + } + receive(draft: VoiceReviewDraft) { + if (draft.revision < this.draft.revision) return; + this.draft = draft; + this.observedAt = performance.now(); + if (this.editHandle === null) this.text = draft.text ?? ""; + this.notify(); + } + setText(text: string) { + this.text = text; + this.notify(); + } + async act(action: VoiceReviewAction, sendAfterSave = false) { + if (this.busy || this.uncertain) return; + if (["released", "deleted", "expired"].includes(this.draft.state)) return; + if (this.draft.state === "editing" && (action === "send-now" || action === "play")) return; + if ((action === "edit-save" || action === "edit-cancel") && this.editHandle === null) return; + this.busy = true; + this.error = null; + this.notify(); + try { + const payload: VoiceReviewMutationPayload = { + expected_revision: this.draft.revision, + ...(action === "edit-save" ? { text: this.text, edit_handle: this.editHandle! } : {}), + ...(action === "edit-cancel" ? { edit_handle: this.editHandle! } : {}), + }; + const result = await this.transport.mutate(this.draft.id, action, payload); + this.draft = result.draft; + this.observedAt = performance.now(); + this.editHandle = result.edit_handle; + if (action === "edit-save" || action === "edit-cancel") this.text = result.draft.text ?? ""; + if (sendAfterSave && action === "edit-save") { + const sent = await this.transport.mutate(this.draft.id, "send-now", { + expected_revision: result.draft.revision, + }); + this.draft = sent.draft; + this.observedAt = performance.now(); + } + } catch (error) { + this.error = voiceReviewError(error); + const tag = + typeof error === "object" && error !== null && "_tag" in error ? error._tag : null; + this.uncertain = + tag !== "VoiceReviewConflictError" && + tag !== "VoiceReviewForbiddenError" && + tag !== "VoiceReviewNotConfiguredError" && + tag !== "VoiceReviewNotFoundError"; + // A possibly effective mutation is observed by the same ID, never retried. + try { + this.draft = await this.transport.get(this.draft.id); + this.observedAt = performance.now(); + } catch { + /* Keep the last acknowledged state until observation succeeds. */ + } + } finally { + this.busy = false; + this.notify(); + } + } + async reconcile() { + try { + this.receive(await this.transport.get(this.draft.id)); + this.uncertain = false; + this.error = null; + } catch (error) { + this.error = voiceReviewError(error); + } + this.notify(); + } +} + +export function remainingSeconds(draft: VoiceReviewDraft, elapsedMs: number) { + if (draft.remaining_ms === null) return null; + return Math.ceil( + Math.max(0, draft.remaining_ms - (draft.state === "held" ? elapsedMs : 0)) / 1000, + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index e6fab4271..b41905991 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WelcomeRouteImport } from './routes/welcome' +import { Route as VoiceReviewRouteImport } from './routes/voice-review' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' @@ -40,6 +41,11 @@ const WelcomeRoute = WelcomeRouteImport.update({ path: '/welcome', getParentRoute: () => rootRouteImport, } as any) +const VoiceReviewRoute = VoiceReviewRouteImport.update({ + id: '/voice-review', + path: '/voice-review', + getParentRoute: () => rootRouteImport, +} as any) const UsageRoute = UsageRouteImport.update({ id: '/usage', path: '/usage', @@ -168,6 +174,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/voice-review': typeof VoiceReviewRoute '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -193,6 +200,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/voice-review': typeof VoiceReviewRoute '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -221,6 +229,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/voice-review': typeof VoiceReviewRoute '/welcome': typeof WelcomeRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -250,6 +259,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/voice-review' | '/welcome' | '/pull-requests' | '/projects/$projectKey' @@ -275,6 +285,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/voice-review' | '/welcome' | '/pull-requests' | '/projects/$projectKey' @@ -302,6 +313,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/voice-review' | '/welcome' | '/_chat/pull-requests' | '/projects/$projectKey' @@ -330,6 +342,7 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute + VoiceReviewRoute: typeof VoiceReviewRoute WelcomeRoute: typeof WelcomeRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } @@ -343,6 +356,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WelcomeRouteImport parentRoute: typeof rootRouteImport } + '/voice-review': { + id: '/voice-review' + path: '/voice-review' + fullPath: '/voice-review' + preLoaderRoute: typeof VoiceReviewRouteImport + parentRoute: typeof rootRouteImport + } '/usage': { id: '/usage' path: '/usage' @@ -574,6 +594,7 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, + VoiceReviewRoute: VoiceReviewRoute, WelcomeRoute: WelcomeRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/voice-review.tsx b/apps/web/src/routes/voice-review.tsx new file mode 100644 index 000000000..89db01403 --- /dev/null +++ b/apps/web/src/routes/voice-review.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { VoiceReviewPage } from "../components/voiceReview/VoiceReviewPage"; + +export const Route = createFileRoute("/voice-review")({ component: VoiceReviewPage }); diff --git a/docs/user/composer.md b/docs/user/composer.md index 8cb8d7805..1f0ffbfc8 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -174,6 +174,20 @@ you had typed in the composer before starting the edit is restored afterwards. I message starts or is removed while you are editing, the edit ends: changed content moves into the composer when it is empty, and is discarded otherwise. +## Review external voice prompts + +On web and desktop, open **Voice review** from the sidebar and choose the +environment connected to your voice source. Voice review requires that environment +to be configured; it does not record audio. Held prompts release after their +countdown. Pause a prompt when you need more time to review it. Double-click its +text, or choose **Edit**, to edit it before release. + +Editing holds release until you save or cancel, and both leave the prompt paused. +Resume the countdown when you are ready, or use **Save and send** to release the +saved text immediately. Closing the page does not resume an edit hold. If a result +is uncertain, check the current state before acting again. Released means handed +off for processing; it does not mean an agent has started. + ## Commands and skills Type `/` for commands or `$` to add a skill from the selected environment and diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index b812495cb..006751277 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./voice-review": { + "types": "./src/voice-review.ts", + "default": "./src/voice-review.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/packages/client-runtime/src/voice-review.test.ts b/packages/client-runtime/src/voice-review.test.ts new file mode 100644 index 000000000..d860e80b5 --- /dev/null +++ b/packages/client-runtime/src/voice-review.test.ts @@ -0,0 +1,160 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, VoiceReviewNotConfiguredError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { RelayConnectionTarget, type PreparedConnection } from "./connection/model.ts"; +import { RemoteEnvironmentAuthorization } from "./authorization/service.ts"; +import { ManagedRelayDpopSigner, type ManagedRelayDpopProofInput } from "./relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "./rpc/http.ts"; +import { + fetchVoiceReviewDrafts, + fetchVoiceReviewDraft, + mutateVoiceReviewDraft, +} from "./voice-review.ts"; + +const target = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("fixture-environment"), + label: "Fixture", +}); +const prepared: PreparedConnection = { + environmentId: target.environmentId, + label: "Fixture", + target, + httpBaseUrl: "https://fixture.test", + socketUrl: "wss://fixture.test/ws", + httpAuthorization: { _tag: "Bearer", token: "fixture-session-token" }, +}; +const draft = { + id: "capture", + source_id: "microphone", + state: "paused", + revision: 2, + text: "literal text", + transcript_provider: "fixture", + language: null, + edited: false, + created_at: "2026-10-02T00:00:00Z", + updated_at: "2026-10-02T00:00:00Z", + due_at: null, + remaining_ms: 180000, + expires_at: "2026-10-03T00:00:00Z", + command_id: null, + command_status: null, + reason: null, + server_now: "2026-10-02T00:00:00Z", +}; +it.effect( + "uses authenticated environment HTTP for list/detail and mutation with snake_case payloads", + () => + Effect.gen(function* () { + const calls: Array<{ url: string; init: RequestInit }> = []; + const layer = remoteHttpClientLayer(async (url, init) => { + calls.push({ url: String(url), init: init ?? {} }); + if (String(url).includes("?")) + return Response.json({ server_now: draft.server_now, drafts: [draft] }); + return Response.json(init?.method === "POST" ? { draft, edit_handle: null } : draft); + }); + const input = { prepared, signer: Option.none() }; + const list = yield* fetchVoiceReviewDrafts({ ...input, scope: "recent", limit: 10 }).pipe( + Effect.provide(layer), + ); + expect(list.drafts).toEqual([draft]); + const detail = yield* fetchVoiceReviewDraft({ ...input, id: "capture" }).pipe( + Effect.provide(layer), + ); + expect(detail).toEqual(draft); + yield* mutateVoiceReviewDraft({ + ...input, + id: "capture", + action: "pause", + payload: { expected_revision: 2 }, + }).pipe(Effect.provide(layer)); + expect(calls[0]?.url).toBe( + "https://fixture.test/api/voice-review/drafts?scope=recent&limit=10", + ); + expect(calls[2]?.url).toBe("https://fixture.test/api/voice-review/drafts/capture/pause"); + const body = yield* Effect.promise(() => new Response(calls[2]?.init.body).text()); + expect(body).toBe('{"expected_revision":2}'); + expect( + calls.every( + ({ init }) => + new Headers(init.headers).get("authorization") === "Bearer fixture-session-token", + ), + ).toBe(true); + }), +); +it.effect( + "exposes a closed not-configured error and leaves old servers as an explicit HTTP failure", + () => + Effect.gen(function* () { + const input = { prepared, signer: Option.none() }; + const closed = remoteHttpClientLayer(async () => + Response.json({ _tag: "VoiceReviewNotConfiguredError" }, { status: 503 }), + ); + const error = yield* fetchVoiceReviewDrafts(input).pipe(Effect.flip, Effect.provide(closed)); + expect(error).toBeInstanceOf(VoiceReviewNotConfiguredError); + const old = remoteHttpClientLayer(async () => new Response("Missing", { status: 404 })); + const oldError = yield* fetchVoiceReviewDrafts(input).pipe(Effect.flip, Effect.provide(old)); + expect(oldError).toMatchObject({ + _tag: "RemoteEnvironmentAuthUndeclaredStatusError", + status: 404, + }); + }), +); +it.effect( + "binds relay DPoP to the selected environment request and never retries ambiguous mutations", + () => + Effect.gen(function* () { + const proofs: ManagedRelayDpopProofInput[] = []; + let calls = 0; + const signer = ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("fixture-thumbprint"), + createProof: (input) => + Effect.sync(() => { + proofs.push(input); + return "fixture-proof"; + }), + }); + const remoteAuthorization = RemoteEnvironmentAuthorization.of({ + authorizeBearer: () => Effect.die("unexpected"), + authorizeDpop: () => Effect.die("unexpected"), + authorizeDpopHttp: () => + Effect.succeed({ + environmentId: target.environmentId, + label: "Fixture", + httpBaseUrl: "https://relay.test", + httpAuthorization: { + _tag: "Dpop", + accessToken: "fresh-token", + expiresAtEpochMs: 3600000, + }, + }), + }); + const layer = remoteHttpClientLayer(async (_url, init) => { + calls++; + expect(new Headers(init?.headers).get("authorization")).toBe("DPoP fresh-token"); + expect(new Headers(init?.headers).get("dpop")).toBe("fixture-proof"); + return Response.json({ _tag: "VoiceReviewUnavailableError" }, { status: 502 }); + }); + const error = yield* mutateVoiceReviewDraft({ + prepared: { + ...prepared, + httpAuthorization: { _tag: "Dpop", accessToken: "old", expiresAtEpochMs: 0 }, + }, + signer: Option.some(signer), + remoteAuthorization: Option.some(remoteAuthorization), + id: "capture", + action: "send-now", + payload: { expected_revision: 2 }, + }).pipe(Effect.flip, Effect.provide(layer)); + expect(error).toMatchObject({ _tag: "VoiceReviewUnavailableError" }); + expect(calls).toBe(1); + expect(proofs).toEqual([ + { + method: "POST", + url: "https://relay.test/api/voice-review/drafts/capture/send-now", + accessToken: "fresh-token", + }, + ]); + }), +); diff --git a/packages/client-runtime/src/voice-review.ts b/packages/client-runtime/src/voice-review.ts new file mode 100644 index 000000000..c75504f32 --- /dev/null +++ b/packages/client-runtime/src/voice-review.ts @@ -0,0 +1,125 @@ +import { + VoiceReviewError, + type VoiceReviewAction, + type VoiceReviewMutationPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { RemoteEnvironmentAuthorization } from "./authorization/service.ts"; +import type { PreparedConnection } from "./connection/model.ts"; +import type { ManagedRelayDpopSigner } from "./relay/managedRelay.ts"; +import { makeEnvironmentHttpApiUrlBuilder } from "./rpc/http.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "./state/environmentHttpAuth.ts"; + +export interface VoiceReviewClientOptions { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly remoteAuthorization?: Option.Option; + readonly timeoutMs?: number; +} +const isReviewError = Schema.is(VoiceReviewError); +const isWrappedFetchError = Schema.is( + Schema.Struct({ + _tag: Schema.Literal("RemoteEnvironmentAuthFetchError"), + cause: Schema.Unknown, + }), +); +const decodeEditSave = Schema.decodeUnknownEffect(VoiceReviewEditSavePayload); +const decodeEditCancel = Schema.decodeUnknownEffect(VoiceReviewEditCancelPayload); +const unwrapReviewError = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => { + if (isReviewError(error)) return error; + if (isWrappedFetchError(error) && isReviewError(error.cause)) return error.cause; + return error; + }), + ); +const authorization = (input: VoiceReviewClientOptions) => + input.remoteAuthorization === undefined + ? Effect.serviceOption(RemoteEnvironmentAuthorization) + : Effect.succeed(input.remoteAuthorization); + +export const fetchVoiceReviewDrafts = Effect.fn("clientRuntime.voiceReview.list")(function* ( + input: VoiceReviewClientOptions & { + readonly scope?: "pending" | "recent"; + readonly limit?: number; + }, +) { + const query = { scope: input.scope ?? "pending", limit: input.limit ?? 50 }; + return yield* unwrapReviewError( + executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization: yield* authorization(input), + group: "voiceReview", + method: "GET", + timeoutMs: input.timeoutMs ?? 15_000, + url: (base) => makeEnvironmentHttpApiUrlBuilder(base).voiceReview.list({ query }), + request: ({ client, headers }) => client.list({ query, headers }), + }), + ); +}); + +export const fetchVoiceReviewDraft = Effect.fn("clientRuntime.voiceReview.get")(function* ( + input: VoiceReviewClientOptions & { readonly id: string }, +) { + const params = { id: input.id }; + return yield* unwrapReviewError( + executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization: yield* authorization(input), + group: "voiceReview", + method: "GET", + timeoutMs: input.timeoutMs ?? 15_000, + url: (base) => makeEnvironmentHttpApiUrlBuilder(base).voiceReview.get({ params }), + request: ({ client, headers }) => client.get({ params, headers }), + }), + ); +}); + +export const mutateVoiceReviewDraft = Effect.fn("clientRuntime.voiceReview.mutate")(function* ( + input: VoiceReviewClientOptions & { + readonly id: string; + readonly action: VoiceReviewAction; + readonly payload: VoiceReviewMutationPayload; + }, +) { + const params = { id: input.id }; + const endpoint = { + pause: "pause", + play: "play", + "edit-begin": "editBegin", + "edit-save": "editSave", + "edit-cancel": "editCancel", + "send-now": "sendNow", + delete: "delete", + } as const; + return yield* unwrapReviewError( + executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization: yield* authorization(input), + group: "voiceReview", + method: "POST", + timeoutMs: input.timeoutMs ?? 25_000, + url: (base) => + makeEnvironmentHttpApiUrlBuilder(base).voiceReview[endpoint[input.action]]({ params }), + request: ({ client, headers }) => { + const args = { params, headers, payload: input.payload }; + switch (input.action) { + case "edit-save": + return Effect.flatMap(decodeEditSave(input.payload), (payload) => + client.editSave({ ...args, payload }), + ); + case "edit-cancel": + return Effect.flatMap(decodeEditCancel(input.payload), (payload) => + client.editCancel({ ...args, payload }), + ); + default: + return client[endpoint[input.action]](args); + } + }, + }), + ); +}); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index ac7047c32..54582375b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -1,3 +1,12 @@ +import { + VoiceReviewDraft, + VoiceReviewDraftList, + VoiceReviewMutationResult, + VoiceReviewErrors, + VoiceReviewRevisionPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, +} from "./voiceReview.ts"; import * as Context from "effect/Context"; import type * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -651,7 +660,101 @@ class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") }), ) {} +const VoiceReviewParams = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), +}); +const VoiceReviewHeaders = OptionalBearerHeaders; +class EnvironmentVoiceReviewHttpApi extends HttpApiGroup.make("voiceReview") + .add( + HttpApiEndpoint.get("list", "/api/voice-review/drafts", { + headers: VoiceReviewHeaders, + query: { + scope: Schema.optional(Schema.Literals(["pending", "recent"])), + limit: Schema.optional( + Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: 200 }), + ), + ), + }, + success: VoiceReviewDraftList, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("get", "/api/voice-review/drafts/:id", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + success: VoiceReviewDraft, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("pause", "/api/voice-review/drafts/:id/pause", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("play", "/api/voice-review/drafts/:id/play", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("editBegin", "/api/voice-review/drafts/:id/edit-begin", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("editSave", "/api/voice-review/drafts/:id/edit-save", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewEditSavePayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("editCancel", "/api/voice-review/drafts/:id/edit-cancel", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewEditCancelPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("sendNow", "/api/voice-review/drafts/:id/send-now", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("delete", "/api/voice-review/drafts/:id/delete", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") + .add(EnvironmentVoiceReviewHttpApi) .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 697fcbe1b..8d2dae099 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./acpRegistry.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; +export * from "./voiceReview.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./desktopAppActivation.ts"; diff --git a/packages/contracts/src/voiceReview.test.ts b/packages/contracts/src/voiceReview.test.ts new file mode 100644 index 000000000..a4eaf8ba6 --- /dev/null +++ b/packages/contracts/src/voiceReview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { + VoiceReviewRevisionPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, +} from "./voiceReview.ts"; + +const decodeRevision = Schema.decodeUnknownSync(VoiceReviewRevisionPayload); +const decodeEditSave = Schema.decodeUnknownSync(VoiceReviewEditSavePayload); +const decodeEditCancel = Schema.decodeUnknownSync(VoiceReviewEditCancelPayload); + +describe("voice review mutation wire contract", () => { + it("rejects coercion, nonpositive revisions, and extra fields", () => { + const decode = decodeRevision; + expect(decode({ expected_revision: 1 })).toEqual({ expected_revision: 1 }); + for (const value of [true, false, "1", 0, -1, 1.5, null]) { + expect(() => decode({ expected_revision: value })).toThrow(); + } + expect(() => decode({ expected_revision: 1, source_id: "other" })).toThrow(); + expect(() => decode({ expected_revision: 1, edit_handle: "secret" })).toThrow(); + }); + it("requires an edit handle and preserves literal text with the 100000 character ceiling", () => { + const decode = decodeEditSave; + const valid = { + expected_revision: 2, + edit_handle: "opaque", + text: " ", + }; + expect(decode(valid)).toEqual(valid); + expect(() => decode({ ...valid, text: " \n " })).toThrow(); + expect(() => decode({ ...valid, text: "a".repeat(100_001) })).toThrow(); + expect(() => decode({ ...valid, edit_handle: "" })).toThrow(); + expect(() => decodeEditCancel({ expected_revision: 1 })).toThrow(); + }); +}); diff --git a/packages/contracts/src/voiceReview.ts b/packages/contracts/src/voiceReview.ts new file mode 100644 index 000000000..2dbd1cbb1 --- /dev/null +++ b/packages/contracts/src/voiceReview.ts @@ -0,0 +1,139 @@ +import * as Schema from "effect/Schema"; +import { NonNegativeInt, PositiveInt, IsoDateTime } from "./baseSchemas.ts"; + +export const VoiceReviewState = Schema.Literals([ + "held", + "paused", + "editing", + "blocked", + "released", + "deleted", + "expired", +]); +export type VoiceReviewState = typeof VoiceReviewState.Type; +export const VoiceReviewReason = Schema.Literals([ + "source_unavailable", + "grant_denied", + "backend_unavailable", + "capacity", +]); +export const VoiceReviewText = Schema.String.check( + Schema.isMaxLength(100_000), + Schema.isPattern(/\S/), +); +export const VoiceReviewDraft = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + source_id: Schema.String, + state: VoiceReviewState, + revision: PositiveInt, + text: Schema.NullOr(VoiceReviewText), + transcript_provider: Schema.String, + language: Schema.NullOr(Schema.String), + edited: Schema.Boolean, + created_at: IsoDateTime, + updated_at: IsoDateTime, + due_at: Schema.NullOr(IsoDateTime), + remaining_ms: Schema.NullOr(NonNegativeInt), + expires_at: IsoDateTime, + command_id: Schema.NullOr(Schema.String), + command_status: Schema.NullOr(Schema.String), + reason: Schema.NullOr(VoiceReviewReason), + server_now: IsoDateTime, +}); +export type VoiceReviewDraft = typeof VoiceReviewDraft.Type; +export const VoiceReviewDraftList = Schema.Struct({ + server_now: IsoDateTime, + drafts: Schema.Array(VoiceReviewDraft).check(Schema.isMaxLength(200)), +}); +export type VoiceReviewDraftList = typeof VoiceReviewDraftList.Type; +export const VoiceReviewMutationResult = Schema.Struct({ + draft: VoiceReviewDraft, + edit_handle: Schema.NullOr(Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096))), +}); +export type VoiceReviewMutationResult = typeof VoiceReviewMutationResult.Type; +export const VoiceReviewAction = Schema.Literals([ + "pause", + "play", + "edit-begin", + "edit-save", + "edit-cancel", + "send-now", + "delete", +]); +export type VoiceReviewAction = typeof VoiceReviewAction.Type; +const strictPayload = (fields: Fields) => + Schema.Record(Schema.String, Schema.Unknown) + .check(Schema.isPropertyNames(Schema.Literals(Object.keys(fields)))) + .pipe(Schema.decodeTo(Schema.Struct(fields))); + +export const VoiceReviewRevisionPayload = strictPayload({ + expected_revision: PositiveInt, +}); +export const VoiceReviewEditSavePayload = strictPayload({ + expected_revision: PositiveInt, + text: VoiceReviewText, + edit_handle: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096)), +}); +export const VoiceReviewEditCancelPayload = strictPayload({ + expected_revision: PositiveInt, + edit_handle: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096)), +}); +export type VoiceReviewMutationPayload = + | typeof VoiceReviewRevisionPayload.Type + | typeof VoiceReviewEditSavePayload.Type + | typeof VoiceReviewEditCancelPayload.Type; + +export class VoiceReviewNotConfiguredError extends Schema.TaggedError()( + "VoiceReviewNotConfiguredError", + {}, + { httpApiStatus: 503 }, +) { + override get message() { + return "Voice review is not configured for this environment."; + } +} +export class VoiceReviewForbiddenError extends Schema.TaggedError()( + "VoiceReviewForbiddenError", + {}, + { httpApiStatus: 403 }, +) { + override get message() { + return "This session cannot access voice review."; + } +} +export class VoiceReviewNotFoundError extends Schema.TaggedError()( + "VoiceReviewNotFoundError", + {}, + { httpApiStatus: 404 }, +) { + override get message() { + return "The voice draft is unavailable."; + } +} +export class VoiceReviewConflictError extends Schema.TaggedError()( + "VoiceReviewConflictError", + {}, + { httpApiStatus: 409 }, +) { + override get message() { + return "The voice draft changed. Refresh it before acting again."; + } +} +export class VoiceReviewUnavailableError extends Schema.TaggedError()( + "VoiceReviewUnavailableError", + {}, + { httpApiStatus: 502 }, +) { + override get message() { + return "Voice review is unavailable. Refresh the draft to observe its current state."; + } +} +export const VoiceReviewErrors = [ + VoiceReviewNotConfiguredError, + VoiceReviewForbiddenError, + VoiceReviewNotFoundError, + VoiceReviewConflictError, + VoiceReviewUnavailableError, +] as const; +export const VoiceReviewError = Schema.Union(VoiceReviewErrors); +export type VoiceReviewError = typeof VoiceReviewError.Type; From ccf20f5f42cc5b12091b3e7ec92c090060850db5 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Mon, 5 Oct 2026 06:10:33 +0200 Subject: [PATCH 24/59] feat(web): restore bounded image and PDF preview zoom --- apps/web/package.json | 1 + .../components/chat/ZoomableImage.test.tsx | 242 ++++++++++ .../web/src/components/chat/ZoomableImage.tsx | 318 +++++++++---- .../files/AttachmentFilePreview.test.tsx | 71 ++- .../files/AttachmentFilePreview.tsx | 18 +- .../components/files/BrowserDocumentFrame.tsx | 36 +- .../src/components/files/FilePreviewPanel.tsx | 26 +- .../src/components/files/PdfPreview.test.tsx | 401 ++++++++++++++++ apps/web/src/components/files/PdfPreview.tsx | 434 ++++++++++++++++++ pnpm-lock.yaml | 134 ++++++ 10 files changed, 1548 insertions(+), 133 deletions(-) create mode 100644 apps/web/src/components/chat/ZoomableImage.test.tsx create mode 100644 apps/web/src/components/files/PdfPreview.test.tsx create mode 100644 apps/web/src/components/files/PdfPreview.tsx diff --git a/apps/web/package.json b/apps/web/package.json index ffccf206f..ef704ef95 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,6 +52,7 @@ "lucide-react": "^0.564.0", "mermaid": "^11.17.2", "morphicons": "^1.7.1", + "pdfjs-dist": "6.3.289", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/chat/ZoomableImage.test.tsx b/apps/web/src/components/chat/ZoomableImage.test.tsx new file mode 100644 index 000000000..b7a3d2b02 --- /dev/null +++ b/apps/web/src/components/chat/ZoomableImage.test.tsx @@ -0,0 +1,242 @@ +// @vitest-environment jsdom +import { act, createRef, type KeyboardEventHandler } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; + +describe("image zoom interactions", () => { + let gallery = vi.fn>(); + let root: Root; + let container: HTMLDivElement; + let resize: (entries: { contentRect: { width: number; height: number } }[]) => void; + const handle = createRef(); + const region = () => container.querySelector('[role="region"]')!; + const input = () => container.querySelector("input")!; + const percent = () => container.querySelector('[aria-live="polite"]')!.textContent; + const image = () => container.querySelector("img")!; + const dispatch = async (target: Element, event: Event) => + act(() => { + target.dispatchEvent(event); + }); + const click = async (target: Element, detail = 1) => + dispatch(target, new MouseEvent("click", { bubbles: true, detail, clientX: 100, clientY: 50 })); + const key = async (target: Element, key: string) => + dispatch(target, new KeyboardEvent("keydown", { bubbles: true, key })); + const edit = async (value: string, commit = "Enter") => { + await act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + input(), + value, + ); + input().dispatchEvent(new Event("input", { bubbles: true })); + }); + if (commit === "blur") await dispatch(input(), new FocusEvent("focusout", { bubbles: true })); + else await key(input(), commit); + }; + const render = async (layout: "dialog" | "panel" = "dialog", src = "fixture.png") => { + await act(() => + root.render( +