diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e10426f81bad8..9619efed05aa1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,7 +22,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.10.1", + "electron": "44.10.2", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 1703a76702024..c9cb2e4fdd42b 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -9,6 +9,10 @@ import org.json.JSONObject import org.json.JSONArray internal object T3ComposerClipboard { + // Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them. + fun hasThreadLink(text: String): Boolean = + text.contains("t3-thread://v2/") || text.contains("://threads/") + fun write(context: Context, text: String, fragment: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val payload = try { diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ecc37df17c993..8181f29082a43 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -697,11 +697,24 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { private fun pasteContextOrImages(): Boolean { val payload = T3ComposerClipboard.read(context) - if (payload["html"]?.contains("data-t3-context-fragment=") == true) { - pasteContextListener?.invoke(payload) - return true + return when { + payload["html"]?.contains("data-t3-context-fragment=") == true -> pasteContext(payload) + pasteImagesOrInterceptedText() -> true + isSingleItemClip() && T3ComposerClipboard.hasThreadLink(payload["text"].orEmpty()) -> + pasteContext(payload) + else -> false } - return pasteImagesOrInterceptedText() + } + + // read() sees only the first item; native paste keeps every item of a longer clip. + private fun isSingleItemClip(): Boolean { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + return clipboard?.primaryClip?.itemCount == 1 + } + + private fun pasteContext(payload: Map): Boolean { + pasteContextListener?.invoke(payload) + return true } private fun pasteImagesOrInterceptedText(): Boolean { diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 99504e6de8936..dc8303aebc8b3 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -4,6 +4,11 @@ import UIKit enum T3ComposerClipboard { static let fragmentType = "app.t3.context-fragment" + // Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them. + static func hasThreadLink(_ text: String) -> Bool { + text.contains("t3-thread://v2/") || text.contains("://threads/") + } + static func write(text: String, fragment: String) { var items: [String: Any] = ["public.utf8-plain-text": text] if let data = fragment.data(using: .utf8), diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index f54f75caf5f50..188ab4a97b316 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -226,6 +226,12 @@ private final class ComposerTextView: UITextView { onPasteText?(text, selectedRange) return } + // read() sees only the first item; native paste keeps every item of a longer pasteboard. + if !bypassTextPasteInterception, pasteboard.numberOfItems == 1, + T3ComposerClipboard.hasThreadLink(context["text", default: ""]) { + onPasteContext?(context) + return + } super.paste(sender) } diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index ae7f9f8a7cc64..c218a39a3a912 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -11,6 +11,7 @@ import { resolveProviderIcon } from "@t3tools/provider-core/client"; import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./ui/AppText"; +import { cn } from "../lib/cn"; import { getProviderClient } from "@t3tools/client-runtime/provider-clients"; type ProviderIconProps = { @@ -161,7 +162,7 @@ export function ProviderInstanceIcon(props: { readonly displayName: string; readonly accentColor?: string; readonly showBadge?: boolean; - readonly surfaceColor: string; + readonly surfaceColor?: string; }) { const accentColor = normalizeProviderAccentColor(props.accentColor); return ( @@ -181,14 +182,14 @@ export function ProviderInstanceIcon(props: { style={{ position: "absolute", right: -3, bottom: -3, width: 32, alignItems: "flex-end" }} > (null); const taskMissing = draft.task !== null && tasks.data !== null && @@ -701,6 +714,9 @@ function TaskForm({ } }; + const offersOnce = + config?.environment.capabilities.oneTimeSchedules === true || + draft.task?.schedule.type === "once"; const save = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -728,6 +744,13 @@ function TaskForm({ ); return; } + if (draft.schedule.mode === "once" && !offersOnce) { + Dialog.alert( + "Once is not available here", + "This environment runs an older T3 Code. Update it to schedule a one-time task, or pick another schedule.", + ); + return; + } const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters); if (draft.schedule.mode === "webhook" && "error" in parsedFilters) { Dialog.alert("Invalid filter", parsedFilters.error); @@ -964,19 +987,31 @@ function TaskForm({ variant="card" options={[ { value: "fixed_time", label: "At a time" }, + // Older servers reject one-time schedules on save. + ...(offersOnce || draft.schedule.mode === "once" + ? [{ value: "once" as const, label: "Once" }] + : []), { value: "interval", label: "Interval" }, { value: "webhook", label: "On webhook" }, ]} selected={draft.schedule.mode} onSelect={(mode) => { setTimePickerOpen(false); + setRunAtPicker(null); setDraft({ ...draft, prompt: mode === "webhook" && !draft.prompt.trim() ? DEFAULT_WEBHOOK_PROMPT : draft.prompt, - schedule: { ...draft.schedule, mode }, + schedule: { + ...draft.schedule, + mode, + runAt: + mode === "once" && !draft.schedule.runAt + ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString() + : draft.schedule.runAt, + }, }); }} /> @@ -1058,6 +1093,53 @@ function TaskForm({ }} /> + ) : draft.schedule.mode === "once" ? ( + (["date", "time"] as const).map((field) => { + const runAt = new Date(draft.schedule.runAt); + const label = field === "date" ? formatRunAtDate(runAt) : formatRunAtTime(runAt); + return ( + + setRunAtPicker((open) => (open === field ? null : field))} + className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" + > + + {field === "date" ? "Date" : "Time"} + + + {label} + + + + {runAtPicker === field ? ( + setRunAtPicker(null)} + onValueChange={(_, selected) => { + // Android shows a dialog that closes on pick; iOS stays inline. + if (Platform.OS === "android") setRunAtPicker(null); + setDraft({ + ...draft, + schedule: { ...draft.schedule, runAt: selected.toISOString() }, + }); + }} + /> + ) : null} + + ); + }) ) : draft.schedule.mode === "webhook" ? ( <> ; readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; @@ -69,6 +71,7 @@ export type ScheduleDraft = { export const DEFAULT_SCHEDULE: ScheduleDraft = { mode: "fixed_time", timeOfDay: "09:00", + runAt: "", weekdays: [1, 2, 3, 4, 5], intervalMinutes: "15", signature: null, @@ -90,6 +93,8 @@ export function scheduleDraftForTask(task: Pick): Sch ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6], }; + case "once": + return { ...DEFAULT_SCHEDULE, mode: "once", runAt: task.schedule.runAt }; case "interval": return { ...DEFAULT_SCHEDULE, @@ -167,6 +172,10 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche : {}), }; } + if (draft.mode === "once") { + const runAt = new Date(draft.runAt); + return Number.isNaN(runAt.getTime()) ? null : { type: "once", runAt: runAt.toISOString() }; + } if (draft.mode === "interval") { const minutes = parseIntervalMinutes(draft.intervalMinutes); const everyMs = Math.round(minutes * 60_000); @@ -216,6 +225,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { .map((option) => [option.id, option.value]), draft.schedule.mode, draft.schedule.timeOfDay, + draft.schedule.runAt, [...draft.schedule.weekdays].sort((a, b) => a - b), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, diff --git a/apps/mobile/src/features/threads/ChildThreadApprovals.tsx b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx new file mode 100644 index 0000000000000..24d7178c6c7c7 --- /dev/null +++ b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx @@ -0,0 +1,88 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { ScrollView, View } from "react-native"; +import Animated, { useAnimatedStyle, type DerivedValue } from "react-native-reanimated"; +import { AppText } from "../../components/ui/AppText"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { PendingApprovalCard } from "./PendingApprovalCard"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + return ( + + + Approval from {entry.title} + + { + if (!canRespond || responding || entry.approval.responseCapability !== "live") return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> + {error !== null ? ( + + {error} + + ) : null} + + ); +} + +export function ChildThreadApprovals({ + threadRef, + maxHeight, +}: { + threadRef: ScopedThreadRef | null; + maxHeight: DerivedValue; +}) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + const heightStyle = useAnimatedStyle(() => ({ maxHeight: Math.min(320, maxHeight.value / 2) })); + if (approvals.length === 0) return null; + return ( + + + {approvals.map((entry) => ( + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx index b68881cb00b98..4bedb9d0cc543 100644 --- a/apps/mobile/src/features/threads/SubagentRow.tsx +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -1,5 +1,9 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; +import { + normalizeProviderAccentColor, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; import type { ReactNode } from "react"; @@ -7,7 +11,7 @@ import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; import { subagentCardDetail } from "./subagent-card-presentation"; @@ -102,9 +106,24 @@ function SubagentMetadata(props: { const { environmentId, subagent } = props; // A task in a linked environment runs a provider instance of that environment. const config = useEnvironmentServerConfig(subagent.remoteChild?.environmentId ?? environmentId); - const provider = config?.providers.find( + const providers = (config?.providers ?? []).map((candidate) => { + const rawConfig = config?.settings.providerInstances[candidate.instanceId]?.config; + const instanceConfig = + rawConfig !== null && typeof rawConfig === "object" + ? (rawConfig as Readonly>) + : null; + const agentId = instanceConfig?.source === "local" ? undefined : instanceConfig?.agentId; + return { + ...candidate, + driverKind: candidate.driver, + accentColor: normalizeProviderAccentColor(candidate.accentColor), + acpRegistryAgentId: typeof agentId === "string" ? agentId.trim() || undefined : undefined, + }; + }); + const provider = providers.find( (candidate) => candidate.instanceId === subagent.providerInstanceId, ); + const showBadge = provider !== undefined && shouldShowInstanceBadge(provider, providers); const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; const child = useThreadShell( subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), @@ -123,12 +142,25 @@ function SubagentMetadata(props: { }); return ( - + {showBadge ? ( + + ) : ( + + )} + {provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""} {modelLabel} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 870ab31f00003..a71920c199f17 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { ChildThreadApprovals } from "./ChildThreadApprovals"; import { useOpenThreadInspector } from "./thread-inspector-navigation"; import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -306,6 +307,8 @@ export interface ThreadDetailScreenProps { readonly showContent?: boolean; } +// iOS questions overlay the controls; Android questions stack with child approvals. +const ComposerPanels = Platform.OS === "android" ? Animated.ScrollView : Animated.View; const USAGE_LIMITS_PANEL_GAP = 12; const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44; @@ -960,6 +963,10 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { composerOverlapHeight: composerBottomInset, }), ); + const composerPanelsHeightStyle = useAnimatedStyle(() => ({ + maxHeight: pendingUserInputMaxHeight.value, + flexGrow: 0, + })); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen. @@ -1504,130 +1511,141 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { }); }} /> - {props.queuedRunEdit !== null ? ( - - { - voiceInputSession.cancel(props.composerDraftKey); - props.onCancelQueuedRunEdit(); - }} - /> - - ) : null} - - - - {props.draftMode ? null : ( - + {props.queuedRunEdit !== null ? ( + + { + voiceInputSession.cancel(props.composerDraftKey); + props.onCancelQueuedRunEdit(); + }} + /> + + ) : null} + - )} - {props.feedbackSubmissions.map((submission) => ( - props.onDismissFeedback(submission.id)} + - ))} - {composerError !== null ? ( - - clearThreadComposerError(selectedThreadKey)} - /> - - ) : null} - {usageLimitsReport && activeUserInputRequestId === null ? ( - - + + {props.draftMode ? null : ( + - - ) : null} - {props.creationState?.kind === "failed" ? ( - - ( + props.onDismissFeedback(submission.id)} /> - - ) : null} - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - + clearThreadComposerError(selectedThreadKey)} /> - ) : null} - {props.activePendingUserInput ? ( - + ) : null} + {usageLimitsReport && activeUserInputRequestId === null ? ( + + - ) : null} - - ) : null} + + ) : null} + {props.creationState?.kind === "failed" ? ( + + + + ) : null} + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b1292b37a3383..01850498a9909 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,4 @@ +import * as Linking from "expo-linking"; import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; @@ -15,7 +16,11 @@ import { enrichedSkillReference, } from "../../lib/enrichedLinkPresentation"; import { ThreadContextDivider } from "./thread-context-divider"; -import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; +import { + ThreadHandoffDivider, + ThreadHandoffRow, + ThreadModelSwitchDivider, +} from "./thread-handoff-row"; import { GuestBubbleTint, GuestMessageName } from "./guestMessageStyle"; import { ThreadGuestPendingMessage, useThreadGuestShare } from "./ThreadGuestShareBar"; import { LinkRequestCard } from "./LinkRequestCard"; @@ -57,7 +62,7 @@ import { } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; +import { appThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks"; import { parseComposerContextHref, @@ -2906,9 +2911,11 @@ export const ThreadFeed = memo(function ThreadFeed({ else if (actionId === "copy-skill") copyTextWithHaptic(`$${skill.name}`); return; } - if (resolveLinkedThread(href)) { + const thread = resolveLinkedThread(href); + if (thread) { if (actionId === "open-thread") onMarkdownLinkPress(href); - else if (actionId === "copy-thread-link") copyTextWithHaptic(href); + else if (actionId === "copy-thread-link") + copyTextWithHaptic(appThreadLink(thread, Linking.resolveScheme({ isSilent: true }))); return; } const target = resolveFileChipTarget(href, props.workspaceRoot); @@ -3513,6 +3520,16 @@ export const ThreadFeed = memo(function ThreadFeed({ workspaceRoot={props.workspaceRoot} > + {info.item.type === "message" && + !isCompactionCommandFeedEntry(info.item) && + info.item.message.projectedItem?.item.type === "user_message" && + info.item.message.projectedItem.item.modelSwitch ? ( + + ) : null} {renderFeedEntry(info, { userMessageGrouping: info.item.userMessageGrouping, environmentId: props.environmentId, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 737e1aff90ada..a7a10a8675122 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -80,7 +80,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; -import { threadEnvironment, useThreadFamily } from "../../state/threads"; +import { threadEnvironment } from "../../state/threads"; import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; import { useProjects } from "../../state/entities"; import { projectThreadContentPresentation } from "./threadContentPresentation"; @@ -243,12 +243,6 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { const connectionsReady = useConnectionsReady(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); - // Subagent rows, the open child's own row and its ancestors come from the family when the - // shell leaves idle children out. - useThreadFamily( - selectedThread?.environmentId ?? null, - selectedThread?.lineage.rootThreadId ?? null, - ); const params = props.route.params ?? {}; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); diff --git a/apps/mobile/src/features/threads/thread-context-divider.tsx b/apps/mobile/src/features/threads/thread-context-divider.tsx index 082f655d11955..19c99f6e63e45 100644 --- a/apps/mobile/src/features/threads/thread-context-divider.tsx +++ b/apps/mobile/src/features/threads/thread-context-divider.tsx @@ -6,7 +6,7 @@ import { ShimmeringWorkContent } from "./thread-work-log"; export function ThreadContextDivider(props: { label: string; - icon: AppSymbolName; + icon?: AppSymbolName; iconColor: ColorValue; active?: boolean; failed?: boolean; @@ -16,7 +16,9 @@ export function ThreadContextDivider(props: { - + {props.icon ? ( + + ) : null} {props.active ? ( { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = + (model ? resolveModelShortName(model, provider?.driver) : undefined) ?? + model?.name ?? + selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function ThreadHandoffRow(props: { environmentId: EnvironmentId; projectedItem: OrchestrationV2ProjectedTurnItem; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 801b309a5f075..fc4ca720161d7 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,4 @@ +import * as Linking from "expo-linking"; import * as Dialog from "../../components/ui/Dialog"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { @@ -957,6 +958,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const target = threadChipCopyTarget( { environmentId: thread.environmentId, threadId: thread.id }, thread.title, + Linking.resolveScheme({ isSilent: true }), ); void copyThreadReference(target).then((didCopy) => { if (!didCopy) Dialog.alert(target.failureTitle, "Try copying this thread again."); diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index cabd4eeb371bb..4a42a83c9a371 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -1,19 +1,23 @@ import { useAtomValue } from "@effect/atom-react"; import { StackActions, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, OrchestrationV2Subagent, OrchestrationV2TurnItem, - ServerProvider, + ServerConfig, } from "@t3tools/contracts"; import { Pressable, View, type ColorValue } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; @@ -42,21 +46,53 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray candidate.instanceId === props.item.providerInstanceId, + ); // A remote task's own driver is only a "remote" label. const driver = - props.provider?.driver ?? - (props.item.remoteChild === undefined ? props.item.driver : undefined); + provider?.driver ?? (props.item.remoteChild === undefined ? props.item.driver : undefined); + const badgeEntries = providers.map((candidate) => { + const rawConfig = props.config?.settings.providerInstances[candidate.instanceId]?.config; + const instanceConfig = + rawConfig !== null && typeof rawConfig === "object" + ? (rawConfig as Readonly>) + : null; + const agentId = instanceConfig?.source === "local" ? undefined : instanceConfig?.agentId; + return { + ...candidate, + driverKind: candidate.driver, + acpRegistryAgentId: typeof agentId === "string" ? agentId.trim() || undefined : undefined, + }; + }); + const badgeEntry = badgeEntries.find( + (candidate) => candidate.instanceId === props.item.providerInstanceId, + ); + const showBadge = + provider !== undefined && + badgeEntry !== undefined && + shouldShowInstanceBadge(badgeEntry, badgeEntries); return ( - {driver === undefined ? ( + {showBadge ? ( + + ) : driver === undefined ? ( ) : ( - + )} ); @@ -100,11 +136,8 @@ export function ThreadSubagentGroup(props: { const expanded = props.expandedRows[props.anchorKey] ?? false; const serverConfigs = useServerConfigs(); // A task in a linked environment runs a provider instance of that environment. - const providerOf = (item: SubagentItem) => - (item.remoteChild === undefined - ? config - : serverConfigs.get(item.remoteChild.environmentId) - )?.providers.find((provider) => provider.instanceId === item.providerInstanceId); + const configOf = (item: SubagentItem) => + item.remoteChild === undefined ? config : serverConfigs.get(item.remoteChild.environmentId); return ( {grouped ? ( @@ -118,7 +151,7 @@ export function ThreadSubagentGroup(props: { {agents.slice(0, 3).map((agent, index) => ( - + ))} {agents.length > 3 ? ( diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index afabd7afb550c..8935d4c9592e4 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -36,6 +36,9 @@ vi.mock("../state/session", () => ({ environmentSession: { preparedConnectionValueAtom: vi.fn() }, })); vi.mock("../state/assets", () => ({ assetEnvironment: { createUrl: (value: unknown) => value } })); +vi.mock("../state/threads", () => ({ + environmentThreadShells: { threadShellAtom: (value: unknown) => value }, +})); vi.mock("../state/use-composer-drafts", () => ({ waitForComposerDraftsLoaded: async () => {}, findLocalComposerClipboardAttachment: mocks.local, @@ -330,4 +333,34 @@ describe("mobile context clipboard imports", () => { collectComposerContextReferences(result!.text).map((reference) => reference.contextId), ).toEqual(result!.context.records.map((record) => record.contextId)); }); + + it("pastes a thread link as a chip only where the destination's agent reads its machine", async () => { + const text = [ + "t3code-unlock://threads/destination/local-thread", + "", + ].join(" "); + const result = await importComposerContextClipboard( + { text, fragment: "", html: "" }, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + ); + const [record] = result!.context.records; + expect(record).toMatchObject({ + kind: "thread", + environmentId: "destination", + threadId: "local-thread", + }); + expect(result!.text).toBe( + `[Thread](t3-context://v1/thread/${record!.contextId}) `, + ); + expect( + await importComposerContextClipboard( + { text: "plain text", fragment: "", html: "" }, + 0, + new AbortController().signal, + ), + ).toBeNull(); + }); }); diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index e7c7201840569..f4ed214e33724 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -13,13 +13,17 @@ import { decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; +import { + threadContextsOutsideEnvironment, + threadLinkContextReferences, +} from "@t3tools/shared/composerContextReferences"; import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { environmentSession } from "../state/session"; +import { environmentThreadShells } from "../state/threads"; import { downloadAttachmentForPreview } from "./attachmentDownload"; import { persistComposerAttachmentFile, @@ -70,11 +74,38 @@ export async function importComposerContextClipboard( /** The machines the destination links to, whose threads its agent can read too. */ linkedEnvironments: ReadonlyMap = new Map(), ) { - const fragment = + const copied = decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); - if (!fragment) return null; - const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records }); - const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4); + // A thread link becomes a chip only where this agent can read its machine; others stay links. + const threads = threadLinkContextReferences( + input.text, + copied?.records ?? [], + (ref) => appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.title, + (record) => + destinationEnvironmentId === undefined || + record.environmentId === destinationEnvironmentId || + linkedEnvironments.has(record.environmentId) + ? record.contextId + : undefined, + ); + const source = + copied?.source ?? + (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null); + if (!source) return null; + const fragment = { + source, + records: [ + ...(copied?.records ?? []).filter((record) => + threads.records.every((thread) => thread.contextId !== record.contextId), + ), + ...threads.records, + ], + }; + const selected = referencedComposerContext(threads.text, { + version: 1, + records: fragment.records, + }); + const imported = reidentifyComposerContext(threads.text, selected?.records ?? [], uuidv4); const skippedThreads = destinationEnvironmentId === undefined ? [] diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 4a3b0174ff67d..2704bf866c211 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -72,7 +72,7 @@ describe("Enriched link presentation", () => { expect( threadChipTargets( here, - "See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a), [B](t3-thread://v1/thread_b) and [D](t3-thread://v2/env-there/thread_d).", + "See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a), [B](t3-thread://v1/thread_b) [D](t3-thread://v2/env-there/thread_d), and [E](t3code-unlock://threads/env-there/thread%252Fe).", [ { version: 1, @@ -94,6 +94,12 @@ describe("Enriched link presentation", () => { threadId: "thread_d", link: true, }, + { + href: "t3code-unlock://threads/env-there/thread%252Fe", + environmentId: there, + threadId: "thread%2Fe", + link: true, + }, { href: "t3-context://v1/thread/thr1", environmentId: there, diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 12859d2dc8bd8..bb3a4e9d35396 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -58,7 +58,7 @@ export function threadChipTargets( ): ReadonlyArray { const targets = new Map(); if (hasThreadLinks(markdown)) { - for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) { + for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:[^\s)]+|t3code[^\s)]+)\)/g)) { const link = parseThreadLinkHref(href); if (link) { targets.set(href, { diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 97a5051577026..362c4c6f14292 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -221,6 +221,7 @@ vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), })); vi.mock("expo-haptics", () => ({ selectionAsync: async () => {} })); +vi.mock("expo-linking", () => ({ resolveScheme: () => "t3code-unlock" })); vi.mock("expo-image", () => ({})); vi.mock("uniwind", () => ({})); vi.mock("../components/ui/Dialog", () => ({})); @@ -268,7 +269,7 @@ vi.mock("./openExternalUrl", () => ({})); vi.mock("./attachmentDownload", () => ({})); vi.mock("./uuid", () => ({})); vi.mock("../features/threads/thread-context-divider", () => ({})); -vi.mock("../features/threads/thread-handoff-row", () => ({})); +vi.mock("../features/threads/thread-handoff-row", () => ({ ThreadModelSwitchDivider: () => null })); vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null })); vi.mock("../features/threads/SecretRequestCard", () => ({})); vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null })); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 4cb0f92b4ee83..bfd088ccf7f44 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, @@ -11,7 +12,6 @@ import { } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -44,18 +44,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); - -/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ -export function useThreadFamily( - environmentId: EnvironmentId | null, - rootThreadId: ThreadId | null, -): void { - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the subagent tree under the root thread into the shared thread registry while the caller shows it - useEffect(() => { - if (environmentId === null || rootThreadId === null) return; - return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId); - }, [environmentId, rootThreadId]); -} +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, +}); // Long enough for the opened screen to subscribe; its subscription keeps the thread after this. const OPENING_THREAD_HOLD_MS = 3_000; diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 9851b841c88c5..ed9fb01fc1f7f 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -6,7 +6,11 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { + needsCursorKeychainAccess, + refreshUsage, + usageSummaryInput, +} from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -52,7 +56,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) => }); continue; } - const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const result = get( + serverEnvironment.usageSummary({ + environmentId, + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/apps/server/scripts/dependency-security.test.ts b/apps/server/scripts/dependency-security.test.ts index 8eeb8889b8eeb..71174ccb0132a 100644 --- a/apps/server/scripts/dependency-security.test.ts +++ b/apps/server/scripts/dependency-security.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { checkDependencySecurity } from "./dependency-security.ts"; +import { + checkDependencySecurity, + checkInstalledClaudeSdk, + checkInstalledCursorSdk, +} from "./dependency-security.ts"; const lock = (entries: string) => `lockfileVersion: '9.0'\nimporters: {}\npackages:\n /bootstrap@1.0.0:\n version: 1.0.0\n---\nimporters:\n apps/server: {}\n apps/desktop: {}\npackages:\n${entries || " {}"}`; @@ -14,6 +18,7 @@ describe("checkDependencySecurity", () => { " /undici@8.12.2-beta.1:\n version: 8.12.2-beta.1", " /undici@6.29.1:\n version: 6.29.1", " /zod@3.25.0:\n version: 3.25.0", + " /electron@44.10.1:\n version: 44.10.1", ].join("\n"), ), ); @@ -22,11 +27,13 @@ describe("checkDependencySecurity", () => { { name: "@modelcontextprotocol/sdk", version: "1.31.0" }, { name: "undici", version: "8.12.2-beta.1" }, { name: "undici", version: "6.29.1" }, + { name: "electron", version: "44.10.1" }, ]); expect(result.violations).toEqual([ { name: "undici", version: "7.29.0", minimum: "7.30.1" }, { name: "@modelcontextprotocol/sdk", version: "1.31.0", minimum: "1.32.0" }, { name: "undici", version: "8.12.2-beta.1", minimum: "8.12.2" }, + { name: "electron", version: "44.10.1", minimum: "44.10.2" }, ]); }); @@ -47,11 +54,21 @@ describe("checkDependencySecurity", () => { }); }); -it("inspects the active server SDK against the approved bundle", async () => { - const { checkInstalledClaudeSdk } = await import("./dependency-security.ts"); +it("matches the active Claude SDK to the approved bundle", async () => { const sdk = await checkInstalledClaudeSdk(); expect(sdk.path).toContain("claude-agent-sdk"); expect(sdk.version).toBe(sdk.expectedVersion); - expect(sdk.sha256).toMatch(/^[a-f0-9]{64}$/); - expect(sdk.violations.length).toBe(sdk.sha256 === sdk.expectedSha256 ? 0 : 1); + expect(sdk.sha256).toBe(sdk.expectedSha256); + expect(sdk.violations).toEqual([]); +}); + +it("matches all active Cursor SDK entry formats to the approved bundles", async () => { + const sdk = await checkInstalledCursorSdk(); + expect(sdk.version).toBe(sdk.expectedVersion); + expect(sdk.bundles.map(({ file }) => file)).toEqual([ + "dist/cjs/704.js", + "dist/esm/704.js", + "dist/bundled/index.js", + ]); + expect(sdk.violations).toEqual([]); }); diff --git a/apps/server/scripts/dependency-security.ts b/apps/server/scripts/dependency-security.ts index cd6e2110881f4..01c3be08d7d27 100644 --- a/apps/server/scripts/dependency-security.ts +++ b/apps/server/scripts/dependency-security.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off - Offline CLI reads one lockfile without an Effect runtime. +// @effect-diagnostics nodeBuiltinImport:off - Offline CLI inspects the lockfile and installed bundles. import * as NodeCrypto from "node:crypto"; import * as NodeModule from "node:module"; import * as NodeFSP from "node:fs/promises"; @@ -19,6 +19,7 @@ const decodeSdkPackage = Schema.decodeUnknownSync(SdkPackage); const decodePackageInfo = Schema.decodeUnknownSync(PackageInfo); const minimums: Readonly>>> = { + electron: { 44: "44.10.2" }, undici: { 6: "6.29.1", 7: "7.30.1", 8: "8.12.2" }, zod: { 4: "4.6.6" }, "body-parser": { 2: "2.4.0" }, @@ -85,9 +86,15 @@ export function checkDependencySecurity(source: string): DependencySecurityResul } const sdkVersion = "0.3.276"; -const sdkSha256 = "7bf61ce680ce009425c2a9db2d6e54a2aff91c756bcdc970459b31b59d446348"; +const sdkSha256 = "e4c986047654f8ad29c11000fefebd7dd9202a1d1f281e7029a0d60e84541622"; const serverRequire = NodeModule.createRequire(new URL("../package.json", import.meta.url)); +async function sha256File(path: string) { + return NodeCrypto.createHash("sha256") + .update(await NodeFSP.readFile(path)) + .digest("hex"); +} + export async function checkInstalledClaudeSdk() { try { const sdkPath = serverRequire.resolve("@anthropic-ai/claude-agent-sdk"); @@ -96,9 +103,7 @@ export async function checkInstalledClaudeSdk() { await NodeFSP.readFile(NodePath.join(NodePath.dirname(sdkPath), "package.json"), "utf8"), ) as unknown, ); - const sha256 = NodeCrypto.createHash("sha256") - .update(await NodeFSP.readFile(sdkPath)) - .digest("hex"); + const sha256 = await sha256File(sdkPath); const violations: string[] = []; if (metadata.version !== sdkVersion) { violations.push(`Expected Claude SDK ${sdkVersion}; found ${metadata.version}.`); @@ -130,29 +135,87 @@ export async function checkInstalledClaudeSdk() { } } +const cursorSdkVersion = "1.0.38"; +const cursorSdkHashes = { + "dist/cjs/704.js": "92b9d8c1a4cf93fd42749085a70f6ea02f27fc9ef6167d521992f918274da4f1", + "dist/esm/704.js": "01c57c4234b5e09771f31edb2e664999cb3b882db3ce9c9b8b091e2a37e0cf0c", + "dist/bundled/index.js": "d89e8355b17cbd7f38cb3b5f76cda56bb7c249816c3438a545c1bfa427eeb65e", +}; + +export function resolveCursorSdkRoot() { + const providerEntry = import.meta.resolve("@t3tools/provider-cursor/server/CursorSdk"); + const entry = NodeModule.createRequire(providerEntry).resolve("@cursor/sdk"); + return NodePath.resolve(NodePath.dirname(entry), "../.."); +} + +export async function checkInstalledCursorSdk() { + try { + const root = resolveCursorSdkRoot(); + const { version } = decodeSdkPackage( + JSON.parse(await NodeFSP.readFile(NodePath.join(root, "package.json"), "utf8")) as unknown, + ); + const bundles = await Promise.all( + Object.entries(cursorSdkHashes).map(async ([file, expectedSha256]) => ({ + file, + sha256: await sha256File(NodePath.join(root, file)), + expectedSha256, + })), + ); + const violations: string[] = []; + if (version !== cursorSdkVersion) { + violations.push(`Expected Cursor SDK ${cursorSdkVersion}; found ${version}.`); + } + for (const { file, sha256, expectedSha256 } of bundles) { + if (sha256 !== expectedSha256) { + violations.push( + `Cursor SDK ${file} SHA-256 ${sha256} differs from approved patched ${expectedSha256}.`, + ); + } + } + return { root, version, expectedVersion: cursorSdkVersion, bundles, violations }; + } catch (error) { + return { + root: null, + version: null, + expectedVersion: cursorSdkVersion, + bundles: [], + violations: [ + `Could not inspect active Cursor SDK: ${error instanceof Error ? error.message : String(error)}`, + ], + }; + } +} + export async function runDependencySecurity( lockfilePath = NodePath.resolve("pnpm-lock.yaml"), json = false, ) { const result = checkDependencySecurity(await NodeFSP.readFile(lockfilePath, "utf8")); - const claudeSdk = await checkInstalledClaudeSdk(); + const [claudeSdk, cursorSdk] = await Promise.all([ + checkInstalledClaudeSdk(), + checkInstalledCursorSdk(), + ]); + const bundleViolations = [...claudeSdk.violations, ...cursorSdk.violations]; if (json) { - process.stdout.write(`${JSON.stringify({ ...result, claudeSdk }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ...result, claudeSdk, cursorSdk }, null, 2)}\n`); } else { process.stdout.write( `Checked ${result.packages.length} resolved packages; ${result.violations.length} below minimums.\n`, ); process.stdout.write( - `Claude SDK embedded v4 array patch: ${claudeSdk.violations.length ? "guard failed" : "approved bundle"}.\n`, + `Claude SDK embedded Zod and qs patches: ${claudeSdk.violations.length ? "guard failed" : "approved bundle"}.\n`, + ); + process.stdout.write( + `Cursor SDK embedded fast-uri patch: ${cursorSdk.violations.length ? "guard failed" : "approved bundles"}.\n`, ); - for (const violation of claudeSdk.violations) process.stdout.write(`${violation}\n`); + for (const violation of bundleViolations) process.stdout.write(`${violation}\n`); for (const violation of result.violations) { process.stdout.write( `${violation.name}@${violation.version} is below ${violation.minimum}\n`, ); } } - if (result.violations.length > 0 || claudeSdk.violations.length > 0) process.exitCode = 1; + if (result.violations.length > 0 || bundleViolations.length > 0) process.exitCode = 1; } if (import.meta.main) { diff --git a/apps/server/scripts/sdk-qs-security.test.ts b/apps/server/scripts/sdk-qs-security.test.ts new file mode 100644 index 0000000000000..63336410cbfbb --- /dev/null +++ b/apps/server/scripts/sdk-qs-security.test.ts @@ -0,0 +1,81 @@ +// @effect-diagnostics nodeBuiltinImport:off - Offline regression evaluates only the embedded query serializer. +import * as NodeAssert from "node:assert/strict"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodeVM from "node:vm"; +import { expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +const require = NodeModule.createRequire(new URL("../package.json", import.meta.url)); + +function loadStringify(source: string) { + const init = source.match(/var P=\(e,t,n\)=>\(\)=>\{if\(e\)try\{[^\n]+?return t\};/)?.[0]; + const start = source.indexOf('var lv="RFC3986"'); + const end = source.indexOf("function nU(e)", start); + NodeAssert.ok(init && start >= 0 && end > start, "Missing Claude SDK query serializer"); + // The excluded oo initializer sets SDK URL helpers that the serializer never reads. + return NodeVM.compileFunction( + `${init}\nconst kn = Array.isArray, oo = () => {};\n${source.slice(start, end)}\ntU(); return eU;`, + )() as ( + input: object, + options: { + arrayFormat: "comma"; + encode?: boolean; + encodeValuesOnly?: boolean; + commaRoundTrip?: boolean; + }, + ) => string; +} + +it.each([ + [false, "a=x%2C%5Bobject%20Object%5D%2Cy", "a=1%2C%5Bobject%20Object%5D"], + [true, "a=x,%5Bobject%20Object%5D,y", "a=1%2C%5Bobject%20Object%5D"], +] as const)( + "safely stringifies comma arrays with encodeValuesOnly=%s", + async (encodeValuesOnly, mixed, nested) => { + const stringify = loadStringify( + await NodeFSP.readFile(require.resolve("@anthropic-ai/claude-agent-sdk"), "utf8"), + ); + const options = { arrayFormat: "comma", encodeValuesOnly } as const; + const shadowed = { toString: "x" }; + expect(stringify({ a: ["x", shadowed, "y"] }, options)).toBe(mixed); + expect(stringify({ a: [[1, [shadowed]]] }, options)).toBe(nested); + expect(stringify({ a: [Object.create(null)] }, options)).toBe("a=%5Bobject%20Object%5D"); + expect(stringify({ a: [shadowed] }, { ...options, commaRoundTrip: true })).toBe( + encodeValuesOnly ? "a[]=%5Bobject%20Object%5D" : "a%5B%5D=%5Bobject%20Object%5D", + ); + expect(stringify({ a: [{ toString: "x", valueOf: () => null }] }, options)).toBe("a=null"); + expect( + stringify( + { a: [{ toString: () => "custom" }, DateTime.toDateUtc(DateTime.makeUnsafe(0))] }, + { ...options, encode: false }, + ), + ).toBe("a=custom,1970-01-01T00:00:00.000Z"); + expect(stringify({ a: [{ ...shadowed, [Symbol.toPrimitive]: () => "p" }] }, options)).toBe( + "a=p", + ); + expect(stringify({ a: ["x", null, undefined, "y"] }, options)).toBe( + encodeValuesOnly ? "a=x,,,y" : "a=x%2C%2C%2Cy", + ); + expect(() => + stringify( + { + a: [ + { + toString() { + throw new SyntaxError("custom error"); + }, + }, + ], + }, + options, + ), + ).toThrow(SyntaxError); + + const cyclic: unknown[] = [shadowed]; + cyclic.push(cyclic); + expect(stringify({ a: cyclic }, { ...options, encode: false })).toBe( + "a=[object Object],[object Object],", + ); + }, +); diff --git a/apps/server/scripts/sdk-uri-security.test.ts b/apps/server/scripts/sdk-uri-security.test.ts new file mode 100644 index 0000000000000..44c8dda2bd3cb --- /dev/null +++ b/apps/server/scripts/sdk-uri-security.test.ts @@ -0,0 +1,94 @@ +// @effect-diagnostics nodeBuiltinImport:off - Offline regression evaluates only embedded URI factories. +import * as NodeAssert from "node:assert/strict"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeVM from "node:vm"; +import { expect, it } from "@effect/vitest"; +import { resolveCursorSdkRoot } from "./dependency-security.ts"; + +const fastUriRoot = "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/"; + +interface FastUri { + parse(uri: string): { scheme?: string; error?: string; resourceName?: string }; + normalize(uri: string): string; + equal(left: string, right: string): boolean; + resolve(base: string, relative: string): string; + serialize(parts: { scheme: string; host?: string; path?: string }): string; +} + +function loadWebpackFastUri(source: string): FastUri { + // The pinned chunk places index, schemes, and utils in one contiguous factory group. + const start = source.indexOf(`"${fastUriRoot}index.js"(`); + const utils = source.indexOf(`"${fastUriRoot}lib/utils.js"(`, start); + const end = source.indexOf('},"../../node_modules/.pnpm/', utils); + NodeAssert.ok(start >= 0 && utils > start && end > utils, "Missing fast-uri factory group"); + type Factory = ( + module: { exports: unknown }, + exports: unknown, + load: (id: string) => unknown, + ) => void; + const factories = NodeVM.compileFunction(`return {${source.slice(start, end + 1)}};`)() as Record< + string, + Factory + >; + NodeAssert.deepEqual(Object.keys(factories), [ + `${fastUriRoot}index.js`, + `${fastUriRoot}lib/schemes.js`, + `${fastUriRoot}lib/utils.js`, + ]); + + const modules = new Map(); + function load(id: string): unknown { + const cached = modules.get(id); + if (cached) return cached.exports; + const factory = factories[id]; + NodeAssert.ok(Object.hasOwn(factories, id) && factory, `Unexpected factory ${id}`); + const module = { exports: {} }; + modules.set(id, module); + factory(module, module.exports, load); + return module.exports; + } + return load(`${fastUriRoot}index.js`) as FastUri; +} + +function loadBunFastUri(source: string): FastUri { + const loader = source.match(/,f=(\(\$,J\)=>\(\)=>[^;]+);/)?.[1]; + const start = source.indexOf("var h81=f("); + const end = source.indexOf("var Xp1=f(", start); + NodeAssert.ok(loader && start >= 0 && end > start, "Missing Bun fast-uri factory group"); + return NodeVM.compileFunction( + `const f = ${loader};${source.slice(start, end)}\nreturn $p1();`, + )() as FastUri; +} + +it.each([ + ["dist/esm/704.js", loadWebpackFastUri], + ["dist/cjs/704.js", loadWebpackFastUri], + ["dist/bundled/index.js", loadBunFastUri], +] as const)("rejects encoded schemes in the Cursor SDK %s", async (file, load) => { + const uri = load(await NodeFSP.readFile(NodePath.join(resolveCursorSdkRoot(), file), "utf8")); + const malformed = "URI scheme is malformed."; + const valid = "https://example.org/"; + for (const [scheme, rest, decoded] of [ + ["%6Aavascript", "alert(1)", "javascript:alert(1)"], + ["%u006Aavascript", "alert(1)", "javascript:alert(1)"], + ["ht%74ps", "//example.org:443", valid], + ] as const) { + const input = `${scheme}:${rest}`; + expect(uri.parse(input)).toMatchObject({ scheme, error: malformed }); + expect(uri.normalize(input)).toBe(input); + expect(uri.equal(input, decoded)).toBe(false); + expect(uri.equal(input, input)).toBe(false); + expect(() => uri.resolve(valid, input)).toThrow(malformed); + expect(() => uri.resolve(input, "child")).toThrow(malformed); + expect(() => uri.serialize({ scheme, path: rest })).toThrow(malformed); + } + + expect(uri.parse("w%73://example.org/?q")).toMatchObject({ error: malformed }); + expect(uri.parse("w%73://example.org/?q").resourceName).toBeUndefined(); + expect(uri.parse("A1+.-:value")).toMatchObject({ scheme: "a1+.-" }); + expect(uri.normalize("HTTPS://EXAMPLE.ORG:443/%7E")).toBe(`${valid}~`); + expect(uri.equal("HTTPS://EXAMPLE.ORG:443", valid)).toBe(true); + expect(uri.resolve(valid, "child?x=%2F")).toBe(`${valid}child?x=%2F`); + expect(uri.serialize({ scheme: "https", host: "example.org", path: "/" })).toBe(valid); +}); diff --git a/apps/server/scripts/sdk-zod-security.test.ts b/apps/server/scripts/sdk-zod-security.test.ts index 99dbff18b75ac..46f9da987d423 100644 --- a/apps/server/scripts/sdk-zod-security.test.ts +++ b/apps/server/scripts/sdk-zod-security.test.ts @@ -1,11 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression instruments the installed SDK in a temporary directory. +// @effect-diagnostics nodeBuiltinImport:off - Offline regression evaluates only the embedded Zod implementation. import * as NodeAssert from "node:assert/strict"; import * as NodeCrypto from "node:crypto"; import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; +import * as NodeVM from "node:vm"; import { it } from "@effect/vitest"; it("rejects oversized leading bounded arrays before traversing elements", async () => { @@ -16,125 +14,115 @@ it("rejects oversized leading bounded arrays before traversing elements", async NodeAssert.ok( [ "b1607967e0dfb39a0db45f143d3b57c7a85f2f6eaa77d6bee7d8184a9830a9f3", - "7bf61ce680ce009425c2a9db2d6e54a2aff91c756bcdc970459b31b59d446348", + "e4c986047654f8ad29c11000fefebd7dd9202a1d1f281e7029a0d60e84541622", ].includes(sha256), `Unexpected SDK bundle ${sha256}; review instrumented aliases`, ); - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-sdk-zod-")); - try { - const instrumented = NodePath.join(directory, "sdk.mjs"); - await NodeFSP.writeFile( - instrumented, - `${source}\nexport { D as testArray, _ as testString, C as testObject, FS as testMax };\n`, - ); - const { - testArray: A, - testString: S, - testObject: O, - testMax: M, - } = await import(/* @vite-ignore */ NodeURL.pathToFileURL(instrumented).href); - let visits = 0; - const element = () => { - const item = S(), - parse = item._zod.parse; - item._zod.parse = (...args: unknown[]) => { - visits++; - return parse(...args); - }; - item._zod.run = item._zod.parse; - return item; - }; - const oversized = ["a", "b", "c"]; - let schema = A(element()).max(2); - let result = schema.safeParse(oversized); - NodeAssert.equal(result.success, false); - NodeAssert.equal(result.error.issues[0].code, "too_big"); - NodeAssert.equal(visits, 0, "oversized input should skip element traversal"); - visits = 0; - NodeAssert.deepEqual(schema.parse(["a", "b"]), ["a", "b"]); - NodeAssert.equal(visits, 2); - result = schema.safeParse([1]); - NodeAssert.equal(result.error.issues[0].code, "invalid_type"); - visits = 0; - result = A(element()).length(2).safeParse(oversized); - NodeAssert.equal(result.error.issues[0].code, "too_big"); - NodeAssert.equal(visits, 0); - visits = 0; - result = A(element()).min(1).max(2).max(1).safeParse(oversized); - NodeAssert.equal(result.error.issues[0].maximum, 2); - NodeAssert.equal(visits, 0); - visits = 0; - result = A(element()).max(5).max(2).safeParse(oversized); - NodeAssert.equal(result.error.issues[0].maximum, 2); - NodeAssert.equal(visits, 0); - visits = 0; - let ran = 0; - schema = A(element()) - .refine(() => { - ran++; - return true; - }) - .max(2); - result = schema.safeParse(oversized); - NodeAssert.equal(visits, 3); - NodeAssert.equal(ran, 1); - NodeAssert.equal(result.error.issues[0].code, "too_big"); - visits = 0; - const max = M(2); - max._zod.def.when = () => false; - schema = A(element()).check(max); - result = schema.safeParse(oversized); - NodeAssert.equal(visits, 3); - NodeAssert.equal(result.success, true); - visits = 0; - schema = A(element()).max(2); - result = schema._zod.run({ value: oversized, issues: [] }, { skipChecks: true, async: false }); - NodeAssert.equal(visits, 3); - NodeAssert.equal(result.issues.length, 0); - visits = 0; - schema._zod.run({ value: oversized, issues: [] }, { direction: "backward", async: false }); - NodeAssert.equal(visits, 6); - visits = 0; - result = O({ items: A(element()).max(2) }).safeParse({ items: oversized }); - NodeAssert.equal(result.error.issues[0].code, "too_big"); - NodeAssert.deepEqual(result.error.issues[0].path, ["items"]); - NodeAssert.equal(visits, 0); - visits = 0; - result = A(element()).max(2, { error: "custom size" }).safeParse(oversized); - NodeAssert.equal(result.error.issues[0].message, "custom size"); - NodeAssert.equal(visits, 0); - visits = 0; - const asyncElement = S().refine(async () => { + const start = source.indexOf("var C4;function M("); + const end = source.indexOf("var wKe=L(", start); + NodeAssert.ok(start >= 0 && end > start, "Missing Claude SDK Zod implementation"); + const { A, S, O, M } = NodeVM.compileFunction( + `${source.slice(start, end)}\nreturn { A: D, S: _, O: C, M: FS };`, + )(); + let visits = 0; + const element = () => { + const item = S(), + parse = item._zod.parse; + item._zod.parse = (...args: unknown[]) => { visits++; + return parse(...args); + }; + item._zod.run = item._zod.parse; + return item; + }; + const oversized = ["a", "b", "c"]; + let schema = A(element()).max(2); + let result = schema.safeParse(oversized); + NodeAssert.equal(result.success, false); + NodeAssert.equal(result.error.issues[0].code, "too_big"); + NodeAssert.equal(visits, 0, "oversized input should skip element traversal"); + visits = 0; + NodeAssert.deepEqual(schema.parse(["a", "b"]), ["a", "b"]); + NodeAssert.equal(visits, 2); + result = schema.safeParse([1]); + NodeAssert.equal(result.error.issues[0].code, "invalid_type"); + visits = 0; + result = A(element()).length(2).safeParse(oversized); + NodeAssert.equal(result.error.issues[0].code, "too_big"); + NodeAssert.equal(visits, 0); + visits = 0; + result = A(element()).min(1).max(2).max(1).safeParse(oversized); + NodeAssert.equal(result.error.issues[0].maximum, 2); + NodeAssert.equal(visits, 0); + visits = 0; + result = A(element()).max(5).max(2).safeParse(oversized); + NodeAssert.equal(result.error.issues[0].maximum, 2); + NodeAssert.equal(visits, 0); + visits = 0; + let ran = 0; + schema = A(element()) + .refine(() => { + ran++; return true; - }); - result = await A(asyncElement).max(2).safeParseAsync(oversized); - NodeAssert.equal(result.error.issues[0].code, "too_big"); - NodeAssert.equal(visits, 0); - visits = 0; - const first = M(1); - first._zod.def.when = () => false; - schema = A(element()).check(first).max(2); - result = schema.safeParse(oversized); - NodeAssert.equal(result.error.issues[0].maximum, 2); - NodeAssert.equal(visits, 0); - visits = 0; - schema = A(element()) - .overwrite((a: string[]) => a.slice(0, 2)) - .max(2); - NodeAssert.deepEqual(schema.parse(oversized), ["a", "b"]); - NodeAssert.equal(visits, 3); - visits = 0; - result = O({ items: A(element()).max(2) }).safeParse({ items: oversized }, { jitless: true }); - NodeAssert.equal(result.error.issues[0].code, "too_big"); - NodeAssert.equal(visits, 0); - visits = 0; - result = A(element()) - .max(2) - .safeParse(oversized, { error: (issue: { code: string }) => `context:${issue.code}` }); - NodeAssert.equal(result.error.issues[0].message, "context:too_big"); - NodeAssert.equal(visits, 0); - } finally { - await NodeFSP.rm(directory, { recursive: true, force: true }); - } + }) + .max(2); + result = schema.safeParse(oversized); + NodeAssert.equal(visits, 3); + NodeAssert.equal(ran, 1); + NodeAssert.equal(result.error.issues[0].code, "too_big"); + visits = 0; + const max = M(2); + max._zod.def.when = () => false; + schema = A(element()).check(max); + result = schema.safeParse(oversized); + NodeAssert.equal(visits, 3); + NodeAssert.equal(result.success, true); + visits = 0; + schema = A(element()).max(2); + result = schema._zod.run({ value: oversized, issues: [] }, { skipChecks: true, async: false }); + NodeAssert.equal(visits, 3); + NodeAssert.equal(result.issues.length, 0); + visits = 0; + schema._zod.run({ value: oversized, issues: [] }, { direction: "backward", async: false }); + NodeAssert.equal(visits, 6); + visits = 0; + result = O({ items: A(element()).max(2) }).safeParse({ items: oversized }); + NodeAssert.equal(result.error.issues[0].code, "too_big"); + NodeAssert.deepEqual(result.error.issues[0].path, ["items"]); + NodeAssert.equal(visits, 0); + visits = 0; + result = A(element()).max(2, { error: "custom size" }).safeParse(oversized); + NodeAssert.equal(result.error.issues[0].message, "custom size"); + NodeAssert.equal(visits, 0); + visits = 0; + const asyncElement = S().refine(async () => { + visits++; + return true; + }); + result = await A(asyncElement).max(2).safeParseAsync(oversized); + NodeAssert.equal(result.error.issues[0].code, "too_big"); + NodeAssert.equal(visits, 0); + visits = 0; + const first = M(1); + first._zod.def.when = () => false; + schema = A(element()).check(first).max(2); + result = schema.safeParse(oversized); + NodeAssert.equal(result.error.issues[0].maximum, 2); + NodeAssert.equal(visits, 0); + visits = 0; + schema = A(element()) + .overwrite((a: string[]) => a.slice(0, 2)) + .max(2); + NodeAssert.deepEqual(schema.parse(oversized), ["a", "b"]); + NodeAssert.equal(visits, 3); + visits = 0; + result = O({ items: A(element()).max(2) }).safeParse({ items: oversized }, { jitless: true }); + NodeAssert.equal(result.error.issues[0].code, "too_big"); + NodeAssert.equal(visits, 0); + visits = 0; + result = A(element()) + .max(2) + .safeParse(oversized, { error: (issue: { code: string }) => `context:${issue.code}` }); + NodeAssert.equal(result.error.issues[0].message, "context:too_big"); + NodeAssert.equal(visits, 0); }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c327b507d9bb7..5f1ed34d23845 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -271,6 +271,7 @@ export const make = Effect.gen(function* () { projectOrganization: true, ignoredFileListing: true, triage: true, + oneTimeSchedules: true, npmReleaseLookup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 0cda9ef352619..59b2fcfe1e8e3 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -738,11 +738,14 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode expect(declaredFailure(update)).toMatchObject({ code: "runtime_mode_escalation_denied" }); const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); expect(declaredFailure(remove)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const once = { type: "once", runAt: "2026-11-01T01:30:00-05:00" }; const allowed = yield* call("schedule_task", { scheduledTaskId: "task-auto", enabled: false, + schedule: once, }); expect(allowed.isError).toBe(false); + expect(allowed.structuredContent).toMatchObject({ schedule: once, enabled: false }); const incomplete = yield* call("schedule_task", { prompt: "Run nightly" }); expect(declaredFailure(incomplete)).toMatchObject({ code: "invalid_request" }); }).pipe( diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 413ab984211f8..7c05f5c8d8e25 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -165,7 +165,7 @@ const ScheduleTaskTool = Tool.make("schedule_task", { failureMode: "return", dependencies, }) - .annotate(Tool.Title, "Schedule a recurring task") + .annotate(Tool.Title, "Schedule a task") .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1777d44eea66b..ff63d581ef72e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -48,6 +48,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RemoteTaskChild, + type OrchestrationV2ModelSwitch, type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2ThreadShell, @@ -73,6 +74,7 @@ import { TrimmedNonEmptyString, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { modelSwitchSince } from "./modelSwitch.ts"; import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; import { derivePendingBackgroundWork, @@ -2197,6 +2199,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio startedAt: now, completedAt: now, updatedAt: now, + ...(activeHandoff === null ? modelSwitchSince(projection, queuedRun.modelSelection) : {}), }; const handoffTurnItem: OrchestrationV2TurnItem | null = activeHandoff === null @@ -4943,6 +4946,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly providerTurnId: typeof providerTurn.id | null; readonly providerThreadId: OrchestrationV2ProviderThread["id"]; readonly providerInstanceId: ProviderInstanceId; + readonly modelSwitch?: OrchestrationV2ModelSwitch | undefined; }) { const message: OrchestrationV2ConversationMessage = { createdBy: input.createdBy, @@ -4998,6 +5002,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: input.text, attachments: input.attachments, ...(input.context ? { context: input.context } : {}), + ...(messageInput.modelSwitch === undefined + ? {} + : { modelSwitch: messageInput.modelSwitch }), }; yield* emitEvent({ type: "message.updated", @@ -5458,6 +5465,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerTurnId: null, providerThreadId: restartProviderThread.id, providerInstanceId: input.modelSelection.instanceId, + // Measured on the pre-restart projection, so the rewritten run is not its own baseline. + ...(restartHandoff === null ? modelSwitchSince(input.projection, input.modelSelection) : {}), }); const interruptedAttemptId = targetRun.activeAttemptId; if (interruptedAttemptId === null) { @@ -6571,6 +6580,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}), }; const preparationTurnItem: OrchestrationV2TurnItem | null = dispatchMode.type === "defer_start" @@ -7322,6 +7332,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(run.contextHandoffId === null + ? modelSwitchSince(projection, modelSelection, sourceRun?.modelSelection) + : {}), }; const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff; const handoffSourceRuns = @@ -9493,9 +9506,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "runtimeRequests", "subagents", ], - { turnItemTypes: [], messageRoles: ["user"] }, + { turnItemTypes: ["user_message"], messageRoles: ["user"] }, ) .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId }))); + if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -9664,7 +9678,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: projection.thread.modelSelection, + modelSelection: queuedRun.modelSelection, targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 4df71fd5a9d63..f1169f1e910b4 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -22,6 +22,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -657,6 +658,151 @@ it.effect("includes shared sessions and provider-owned background rosters in rec }).pipe(Effect.provide(layerTest)), ); +it.effect("bounds runtime provider-thread reads by ownership and preserves recovery liveness", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const threadId = yield* createThread("ownership"); + const other = yield* createThread("other-owner"); + const ownerNodeId = NodeId.make("node:recovery:owner"); + yield* projections.apply({ + id: EventId.make("event:recovery:owner"), + type: "node.updated", + threadId, + nodeId: ownerNodeId, + driver, + occurredAt: now, + payload: { + id: ownerNodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: ownerNodeId, + kind: "root_turn", + status: "completed", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }, + }); + const cases = [ + { name: "direct", ownership: "direct", status: "active", ordinal: 2, included: true }, + { name: "owner", ownership: "owner", status: "active", ordinal: null, included: true }, + { name: "owner-only", ownership: "owner", status: "active", ordinal: 1, included: true }, + { name: "subagent", ownership: "subagent", status: "active", ordinal: 1, included: true }, + { name: "duplicate", ownership: "all", status: "active", ordinal: 1, included: true }, + { name: "roster", ownership: "direct", status: "idle", ordinal: 3, included: true }, + { name: "run", ownership: "owner", status: "idle", ordinal: 4, included: true }, + { name: "item", ownership: "subagent", status: "idle", ordinal: 5, included: true }, + { name: "waiting-run", ownership: "direct", status: "idle", ordinal: 6, included: true }, + { name: "settled", ownership: "all", status: "idle", ordinal: 7, included: false }, + { name: "unrelated", ownership: "none", status: "active", ordinal: 8, included: false }, + { name: "unrelated-run", ownership: "none", status: "idle", ordinal: 9, included: false }, + ] as const; + for (const entry of cases) { + const id = ProviderThreadId.make(`provider-thread:ownership:${entry.name}`); + yield* projections.apply({ + id: EventId.make(`event:${id}`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + appThreadId: + entry.name === "owner-only" + ? null + : entry.ownership === "direct" || entry.ownership === "all" + ? threadId + : other, + ownerNodeId: + entry.ownership === "owner" || entry.ownership === "all" ? ownerNodeId : null, + driver, + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: entry.status, + firstRunOrdinal: entry.ordinal, + lastRunOrdinal: entry.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: + entry.name === "roster" + ? [{ taskId: "shell", description: "background", kind: "command" }] + : [], + }, + }); + if (entry.ownership === "subagent" || entry.ownership === "all") { + for (const suffix of ["a", "b"]) { + yield* sql` + INSERT INTO orchestration_v2_projection_subagents + (subagent_id, thread_id, parent_node_id, provider, provider_thread_id, + origin, status, updated_at, payload_json) + VALUES (${`${id}:${suffix}`}, ${threadId}, ${ownerNodeId}, 'codex', ${id}, + 'provider_native', 'completed', '', '{}') + `; + } + } + if (entry.name === "run" || entry.name === "unrelated-run") { + yield* createRun(threadId, "waiting", { ordinal: entry.ordinal, providerThreadId: id }); + } + } + const waitingProviderThreadId = ProviderThreadId.make("provider-thread:ownership:waiting-run"); + const waitingRunId = yield* createRun(threadId, "completed", { + ordinal: 6, + providerThreadId: waitingProviderThreadId, + }); + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items + (turn_item_id, thread_id, run_id, node_id, provider_thread_id, ordinal, type, + status, updated_at, payload_json) + VALUES ('item:ownership', ${threadId}, ${waitingRunId}, ${ownerNodeId}, + ${ProviderThreadId.make("provider-thread:ownership:item")}, 1, 'assistant_message', 'waiting', '', '{}') + `; + const statements: Array["compile"]>> = []; + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + if (compiled[0].includes("SELECT provider_thread.payload_json")) statements.push(compiled); + return Effect.succeed(statement); + }; + const projection = yield* projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.provideService(Statement.CurrentTransformer, observe)); + assert.deepEqual( + projection.providerThreads.map((row) => row.id), + cases + .filter((entry) => entry.included) + .toSorted((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0) || a.name.localeCompare(b.name)) + .map((entry) => ProviderThreadId.make(`provider-thread:ownership:${entry.name}`)), + ); + assert.equal(statements.length, 1); + const [query, params] = statements[0]!; + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + assert.isFalse( + plan.some((row) => /^SCAN provider_thread(?:$| )/.test(row.detail)), + JSON.stringify(plan), + ); + assert.isTrue( + plan.some((row) => row.detail.includes("provider_threads_owner_idx")), + JSON.stringify(plan), + ); + assert.isFalse( + plan.some((row) => /SEARCH (owner|n) USING INDEX .*nodes_thread/.test(row.detail)), + JSON.stringify(plan), + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect("reads the run that owns a background roster, not a queued or resumed one", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 31094caf7d6f2..4d6f9f166a5f4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -4587,18 +4587,20 @@ export const layer: Layer.Layer = sql` SELECT provider_thread.payload_json FROM orchestration_v2_projection_provider_threads AS provider_thread - WHERE ( - provider_thread.thread_id = ${threadId} - OR EXISTS ( - SELECT 1 FROM orchestration_v2_projection_nodes AS owner - WHERE owner.node_id = provider_thread.owner_node_id - AND owner.thread_id = ${threadId} - ) - OR EXISTS ( - SELECT 1 FROM orchestration_v2_projection_subagents AS subagent - WHERE subagent.provider_thread_id = provider_thread.provider_thread_id - AND subagent.thread_id = ${threadId} - ) + WHERE provider_thread.provider_thread_id IN ( + SELECT provider_thread_id FROM orchestration_v2_projection_provider_threads + WHERE thread_id = ${threadId} + UNION + -- Start with non-null owners; a thread can have a large settled node history. + SELECT owned.provider_thread_id + FROM orchestration_v2_projection_provider_threads AS owned + INDEXED BY orchestration_v2_projection_provider_threads_owner_idx + CROSS JOIN orchestration_v2_projection_nodes AS owner + ON owner.node_id = owned.owner_node_id + WHERE owned.owner_node_id IS NOT NULL AND owner.thread_id = ${threadId} + UNION + SELECT provider_thread_id FROM orchestration_v2_projection_subagents + WHERE thread_id = ${threadId} AND provider_thread_id IS NOT NULL ) AND ( provider_thread.status = 'active' diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index d059c4c20e9d7..4352d8b2d6477 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ThreadProjection, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, ProjectId, @@ -47,6 +48,10 @@ const seedSelection = { instanceId: providerInstanceId, model: "seed-model", } satisfies ModelSelection; +const modelSwitches = (projection: OrchestrationV2ThreadProjection) => + projection.turnItems.flatMap((item) => + item.type === "user_message" && item.modelSwitch !== undefined ? [item.modelSwitch] : [], + ); const pooledCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexProviderCapabilitiesV2, @@ -407,6 +412,9 @@ it.live("retries a failed selection restart session open before completing", () "selection restart supersede must not project hard-Stop interrupt items", ); assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model); + assert.deepEqual(modelSwitches(projection), [ + { from: initialSelection, to: replacementSelection }, + ]); assert.equal(projection.runs.at(-1)?.status, "completed"); assert.isTrue(captured.failedReplacementOpen); assert.equal(captured.closedSessionCount, 0); @@ -690,6 +698,9 @@ it.live( const { projection, captured } = result; assert.lengthOf(projection.runs, 2); assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); + assert.deepEqual(modelSwitches(projection), [ + { from: seedSelection, to: replacementSelection }, + ]); assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); assert.equal(captured.closedSessionCount, 1); assert.deepEqual( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index d4867a264ec9c..d4c6235d23015 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1132,6 +1132,97 @@ it.effect("keeps a held commentary draft before the steer when turn end flushes ), ); +it.effect("steers a promoted queued message with the selection it was queued with", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-queued-selection", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + modelSelection: composerSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["queued"]); + assert.deepEqual(projection.thread.modelSelection, composerSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("marks the model switch on a promoted message that restarts the turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-restart-switch", + true, + false, + ); + const otherSelection = { ...runSelection, model: "other-model" }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + modelSelection: otherSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const promoted = (yield* orchestrator.getThreadProjection(threadId)).turnItems.find( + (item) => item.type === "user_message" && item.messageId === "message:queued", + ); + assert.deepEqual(promoted?.type === "user_message" ? promoted.modelSwitch : undefined, { + from: runSelection, + to: otherSelection, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("keeps a queued message queued when promoted into a run that Stop reached", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/modelSwitch.test.ts b/apps/server/src/orchestration-v2/modelSwitch.test.ts new file mode 100644 index 0000000000000..940340074c1f2 --- /dev/null +++ b/apps/server/src/orchestration-v2/modelSwitch.test.ts @@ -0,0 +1,107 @@ +import { + ContextHandoffId, + MessageId, + ProviderInstanceId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { modelSwitchSince } from "./modelSwitch.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const selection = (model: string) => ({ instanceId, model }); +const run = ( + message: string, + ordinal: number, + model: string, + status: OrchestrationV2ThreadProjection["runs"][number]["status"] = "completed", +) => + ({ + ordinal, + status, + completedAt: null, + contextHandoffId: null, + userMessageId: MessageId.make(message), + modelSelection: selection(model), + }) as OrchestrationV2ThreadProjection["runs"][number]; +const shown = (...messages: ReadonlyArray) => + messages.map((message) => ({ + type: "user_message", + messageId: MessageId.make(message), + inputIntent: "turn_start", + status: "completed", + text: message, + attachments: [], + })) as unknown as OrchestrationV2ThreadProjection["turnItems"]; + +describe("modelSwitchSince", () => { + it("measures from the latest run whose message is in the timeline", () => { + // B failed before it started; its message shows "a -> b", so the next one must show "b -> a". + expect( + modelSwitchSince( + { + runs: [ + run("first", 1, "a"), + run("failed", 2, "b", "failed"), + run("queued", 3, "c", "queued"), + run("undone", 4, "d", "rolled_back"), + ], + turnItems: shown("first", "failed", "queued", "undone"), + }, + selection("a"), + ), + ).toEqual({ modelSwitch: { from: selection("b"), to: selection("a") } }); + }); + + it("skips messages clients hide", () => { + const [first, pending, compact, answer] = shown( + "first", + "pending", + "/compact", + "async-answer:question", + ); + expect( + modelSwitchSince( + { + runs: [ + run("first", 1, "a"), + run("pending", 2, "b", "cancelled"), + run("/compact", 3, "c"), + run("async-answer:question", 4, "d"), + ], + turnItems: [ + first!, + { ...pending!, inputIntent: "queued_turn", status: "pending" }, + compact!, + answer!, + ] as OrchestrationV2ThreadProjection["turnItems"], + }, + selection("c"), + ), + ).toEqual({ modelSwitch: { from: selection("a"), to: selection("c") } }); + }); + + it("counts a wake whose handoff row shows the swap", () => { + const wake = { + ...run("wake", 2, "other"), + contextHandoffId: ContextHandoffId.make("handoff"), + }; + expect( + modelSwitchSince( + { runs: [run("first", 1, "a"), wake], turnItems: shown("first") }, + selection("other"), + ), + ).toEqual({}); + }); + + it("records nothing for a first run or an unchanged selection", () => { + expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"))).toEqual({}); + // A fork's first message compares with the run it forked from. + expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"), selection("b"))).toEqual({ + modelSwitch: { from: selection("b"), to: selection("a") }, + }); + expect( + modelSwitchSince({ runs: [run("first", 1, "a")], turnItems: shown("first") }, selection("a")), + ).toEqual({}); + }); +}); diff --git a/apps/server/src/orchestration-v2/modelSwitch.ts b/apps/server/src/orchestration-v2/modelSwitch.ts new file mode 100644 index 0000000000000..5b893cd0026d2 --- /dev/null +++ b/apps/server/src/orchestration-v2/modelSwitch.ts @@ -0,0 +1,42 @@ +import { + isCompactionCommandMessage, + type ModelSelection, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; + +/** + * The model or effort change a new run brings, measured from the latest run the timeline shows + * through its message or its handoff row, so consecutive dividers always chain. `baseline` stands + * in when there is none, such as a fork's source run. Callers skip starts with a context handoff; + * the handoff item already shows those. + */ +export function modelSwitchSince( + projection: Pick, + to: ModelSelection, + baseline?: ModelSelection, +): Pick, "modelSwitch"> { + // Clients hide pending queued messages, fold async answers into their question, and replace + // `/compact` with a compaction row. + const shown = new Set( + projection.turnItems.flatMap((item) => + item.type === "user_message" && + !(item.inputIntent === "queued_turn" && item.status === "pending") && + !item.messageId.startsWith("async-answer:") && + !isCompactionCommandMessage({ role: "user", ...item }) + ? [item.messageId] + : [], + ), + ); + let previous: OrchestrationV2ThreadProjection["runs"][number] | undefined; + for (const run of projection.runs) { + if (run.status === "rolled_back" || run.status === "queued") continue; + // A wake shows as a notification, but its context handoff row still shows the swap. + if (!shown.has(run.userMessageId) && run.contextHandoffId === null) continue; + if (previous === undefined || runRanAfter(run, previous)) previous = run; + } + const from = previous?.modelSelection ?? baseline; + return from === undefined || modelSelectionsEqual(from, to) ? {} : { modelSwitch: { from, to } }; +} diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..778b8c97ae0a2 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -157,6 +157,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect( + yield* fileSystem.readLink(path.join(shadowHome, "session_index.jsonl.lock")), + ).toBe(path.join(sharedHome, "session_index.jsonl.lock")); expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f6d2ba9d44cf5..ceddde9072a40 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -400,7 +400,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" }), }), ); - const entries = new Set(KNOWN_SHARED_DIRECTORIES); + // Link the index lock before Codex creates it so both homes serialize shared index writes. + const entries = new Set([...KNOWN_SHARED_DIRECTORIES, "session_index.jsonl.lock"]); for (const entryName of sharedEntryNames) { if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { entries.add(entryName); diff --git a/apps/server/src/provider/ProviderBilling.test.ts b/apps/server/src/provider/ProviderBilling.test.ts index 99ae67832ed6a..d7a3d2d812121 100644 --- a/apps/server/src/provider/ProviderBilling.test.ts +++ b/apps/server/src/provider/ProviderBilling.test.ts @@ -67,7 +67,11 @@ it.effect( }); assert.notInclude(JSON.stringify(result), "admin_synthetic_key"); assert.notInclude( - JSON.stringify(ServerSettings.redactServerSettingsForClient(yield* settings.getSettings)), + JSON.stringify( + ServerSettings.redactServerSettingsForClient(yield* settings.getSettings, { + secretReferences: true, + }), + ), "admin_synthetic_key", ); }).pipe(Effect.provide(fixture("admin_synthetic_key"))), @@ -168,7 +172,11 @@ it.effect( consoleBilling: encodeClaudeConsoleBillingConnection(connection), }); assert.notInclude( - JSON.stringify(ServerSettings.redactServerSettingsForClient(yield* settings.getSettings)), + JSON.stringify( + ServerSettings.redactServerSettingsForClient(yield* settings.getSettings, { + secretReferences: true, + }), + ), connection.organizationId, ); }).pipe(Effect.provide(claudeFixture())), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index dedfd3d97dae5..70bc8d1066da9 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -15,6 +15,9 @@ import { type ResolvedProviderInstanceEnvironment, ProviderInstanceId, SecretProviderId, + SecretProviderKind, + ServerSettingsError, + type ServerSettings as ServerSettingsValue, SettingsSecretValue, type SecretReference, } from "@t3tools/contracts"; @@ -31,6 +34,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; @@ -49,6 +53,7 @@ import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestra import * as PortScanner from "../preview/PortScanner.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import { defineSecretProviderDriver } from "@t3tools/secret-provider-core/server/driver"; import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -977,3 +982,253 @@ describe("ProviderInstanceRegistry secret environment", () => { }).pipe(Effect.scoped); }); }); + +describe("ProviderInstanceRegistry settings snapshots", () => { + const instanceId = ProviderInstanceId.make("snapshot_instance"); + const driverKind = ProviderDriverKind.make("snapshot"); + const provider = SecretProviderId.make("fixture"); + const kind = SecretProviderKind.make("fixture"); + const reference = "fixture://shared-item"; + const configSchema = Schema.Struct({ destination: Schema.String, apiKey: SettingsSecretValue }); + const accountSchema = Schema.Struct({ account: Schema.String }); + + const snapshot = (destination: string, account: string) => ({ + configMap: { + [instanceId]: { + driver: driverKind, + config: { destination, apiKey: { provider, reference } }, + }, + } satisfies ProviderInstanceConfigMap, + secretProviders: { + [provider]: { kind, label: "Fixture vault", config: { account } }, + } satisfies ServerSettingsValue["secretProviders"], + }); + const a = snapshot("destination-A", "A"); + const b = snapshot("destination-B", "B"); + const empty = { ...a, configMap: {} }; + const pair = (destination: string, account: string) => ({ + destination, + apiKey: `synthetic-key-${account}`, + }); + const settingsError = new ServerSettingsError({ + settingsPath: "fixture", + operation: "read-file", + }); + + const setup = Effect.fn("ProviderInstanceRegistry.testSnapshots")(function* ( + seed: typeof a, + gateAccount: string, + ) { + const current = yield* Ref.make(seed); + const entered = yield* Deferred.make>(); + const release = yield* Deferred.make(); + const reads: string[] = []; + const creates: unknown[] = []; + const sourceReads: Array = []; + const failSource = yield* Ref.make(false); + const readCurrentSnapshot = Effect.gen(function* () { + if (yield* Ref.get(failSource)) return yield* settingsError; + const latest = yield* Ref.get(current); + sourceReads.push(latest); + return { ...latest, configMap: { ...latest.configMap } }; + }); + const resolver = yield* SecretResolver.make({ + secretProviders: Ref.get(current).pipe(Effect.map((settings) => settings.secretProviders)), + drivers: [ + defineSecretProviderDriver({ + kind, + definition: { + kind, + label: "Fixture vault", + configSchema: accountSchema, + isReference: (value) => value === reference, + referencePlaceholder: reference, + invalidReferenceMessage: "Enter a fixture reference.", + }, + read: ({ account }, references) => + Effect.withFiber((fiber) => + Effect.gen(function* () { + reads.push(account); + if (account === gateAccount && (yield* Deferred.succeed(entered, fiber))) { + yield* Deferred.await(release); + } + return references.map(() => + account === "unreadable" ? undefined : `synthetic-key-${account}`, + ); + }), + ), + }), + ], + }); + const driver: AnyProviderDriver = { + driverKind, + metadata: { displayName: "Snapshot fixture" }, + configSchema, + defaultConfig: () => ({}), + create: (input) => + Effect.sync(() => { + creates.push(input.config); + const instance = { + instanceId: input.instanceId, + enabled: input.enabled, + } as ProviderInstance; + return { ...instance, config: input.config }; + }), + }; + const input = { drivers: [driver], snapshot: seed, readCurrentSnapshot }; + const built = yield* makeProviderInstanceRegistry(input).pipe( + Effect.provideService(SecretResolver.SecretResolver, resolver), + ); + return { ...built, current, entered, release, reads, creates, sourceReads, failSource }; + }); + + const test = (run: Effect.Effect) => + run.pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + + it.effect("uses the reconciliation snapshot throughout a batched vault read", () => + test( + Effect.gen(function* () { + const f = yield* setup(empty, "A"); + const changes = yield* f.registry.subscribeChanges; + yield* Ref.set(f.current, a); + const reconciling = yield* f.mutator.reconcile(a.configMap).pipe(Effect.forkScoped); + yield* Deferred.await(f.entered); + yield* Ref.set(f.current, b); + yield* Deferred.succeed(f.release, undefined); + yield* Fiber.join(reconciling); + expect(yield* PubSub.takeUpTo(changes, 2)).toEqual([undefined]); + expect(f.creates).toEqual([pair("destination-A", "A")]); + expect(yield* f.registry.getInstance(instanceId)).toMatchObject({ + config: pair("destination-A", "A"), + }); + yield* f.mutator.reconcile(); + expect(yield* PubSub.takeUpTo(changes, 2)).toEqual([undefined]); + expect(f.creates).toEqual([pair("destination-A", "A"), pair("destination-B", "B")]); + expect(yield* f.registry.getInstance(instanceId)).toMatchObject({ + config: pair("destination-B", "B"), + }); + expect(f.reads).toEqual(["A", "B"]); + }), + ), + ); + + it.effect.each([b, snapshot("destination-A", "B")])( + "discards startup completion if either half of the snapshot changed (%#)", + (latest) => + test( + Effect.gen(function* () { + const f = yield* setup(a, "A"); + const changes = yield* f.registry.subscribeChanges; + const startup = yield* Deferred.await(f.entered); + expect(yield* f.registry.listUnavailable).toMatchObject([ + { unavailableReason: "Reading secrets…" }, + ]); + yield* Ref.set(f.current, latest); + yield* Deferred.succeed(f.release, undefined); + expect(yield* Fiber.await(startup)).toEqual(Exit.void); + expect(f.creates).toEqual([]); + expect(yield* f.registry.getInstance(instanceId)).toBeUndefined(); + expect(yield* PubSub.takeUpTo(changes, 1)).toEqual([]); + yield* f.mutator.reconcile(); + expect(yield* PubSub.takeUpTo(changes, 2)).toEqual([undefined]); + const destination = latest === b ? "destination-B" : "destination-A"; + expect(f.creates).toEqual([pair(destination, "B")]); + expect(yield* f.registry.getInstance(instanceId)).toMatchObject({ + config: pair(destination, "B"), + }); + expect(f.reads).toEqual(["A", "B"]); + }), + ), + ); + + it.effect.each(["targeted", "all", "account-only", "unreadable", "settings failure"] as const)( + "samples current settings on %s refresh", + (scenario) => + test( + Effect.gen(function* () { + const f = yield* setup(a, "A"); + const changes = yield* f.registry.subscribeChanges; + const startup = yield* Deferred.await(f.entered); + yield* Deferred.succeed(f.release, undefined); + expect(yield* Fiber.await(startup)).toEqual(Exit.void); + expect(yield* PubSub.takeUpTo(changes, 2)).toEqual([undefined]); + const before = yield* f.registry.getInstance(instanceId); + expect(before).toMatchObject({ config: pair("destination-A", "A") }); + + const destination = scenario === "account-only" ? "destination-A" : "destination-B"; + yield* Ref.set( + f.current, + snapshot(destination, scenario === "unreadable" ? "unreadable" : "B"), + ); + if (scenario === "account-only") { + yield* f.mutator.reconcile(a.configMap); + expect(yield* f.registry.getInstance(instanceId)).toBe(before); + expect(f.reads).toEqual(["A"]); + } + const reload = f.mutator.reloadSecrets(scenario === "all" ? undefined : instanceId); + if (scenario === "settings failure") { + yield* Ref.set(f.failSource, true); + expect(yield* Effect.result(reload)).toMatchObject({ + _tag: "Failure", + failure: settingsError, + }); + expect(yield* Effect.result(f.mutator.reconcile())).toMatchObject({ + _tag: "Failure", + failure: settingsError, + }); + expect(yield* f.registry.getInstance(instanceId)).toBe(before); + expect(f.creates).toEqual([pair("destination-A", "A")]); + expect(f.reads).toEqual(["A"]); + expect(yield* PubSub.takeUpTo(changes, 1)).toEqual([]); + return; + } + yield* reload; + expect(yield* PubSub.takeUpTo(changes, 2)).toEqual([undefined]); + if (scenario === "unreadable") { + expect(yield* f.registry.getInstance(instanceId)).toBeUndefined(); + expect(yield* f.registry.listUnavailable).toMatchObject([ + { unavailableReason: expect.stringContaining("Could not read apiKey") }, + ]); + expect(f.creates).toEqual([pair("destination-A", "A")]); + expect(f.reads).toEqual(["A", "unreadable"]); + return; + } + expect(f.creates).toEqual([pair("destination-A", "A"), pair(destination, "B")]); + expect(yield* f.registry.getInstance(instanceId)).toMatchObject({ + config: pair(destination, "B"), + }); + expect(f.reads).toEqual(["A", "B"]); + }), + ), + ); + + it.effect( + "samples queued reconciliation after the preceding refresh acquires newer settings", + () => + test( + Effect.gen(function* () { + const f = yield* setup(empty, "A"); + yield* Ref.set(f.current, a); + const first = yield* f.mutator.reconcile(a.configMap).pipe(Effect.forkScoped); + yield* Deferred.await(f.entered); + const refresh = yield* f.mutator + .reloadSecrets() + .pipe(Effect.forkScoped({ startImmediately: true })); + const watcher = yield* f.mutator + .reconcile(a.configMap) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Ref.set(f.current, b); + yield* Deferred.succeed(f.release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(refresh); + yield* Fiber.join(watcher); + expect(yield* f.registry.getInstance(instanceId)).toMatchObject({ + config: pair("destination-B", "B"), + }); + expect(f.creates).toEqual([pair("destination-A", "A"), pair("destination-B", "B")]); + expect(f.sourceReads).toEqual([a, b, b]); + expect(f.reads).toEqual(["A", "B"]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index a944f751a1d34..2ee80983af7dc 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -9,6 +9,10 @@ import { type SecretReference, secretReferencesInSettings, type ServerProvider, + type ServerSettings, + type ServerSettingsError, + type SettingsSecretSlot, + withSettingsSecrets, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -45,7 +49,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface InstanceSecrets { readonly environment: ResolvedProviderInstanceEnvironment; - readonly config: Readonly>; + readonly config: ReadonlyArray; } interface LiveEntry { @@ -61,6 +65,24 @@ interface RegistryState { readonly changes: PubSub.PubSub; } +interface RegistrySnapshot { + readonly configMap: ProviderInstanceConfigMap; + readonly secretProviders?: ServerSettings["secretProviders"]; +} + +type RegistryInput = { + readonly drivers: ReadonlyArray>; +} & ( + | { readonly configMap: ProviderInstanceConfigMap } + | { + readonly snapshot: Required; + readonly readCurrentSnapshot: Effect.Effect, ServerSettingsError>; + } +); + +const configMapsEqual = (a: ProviderInstanceConfigMap, b: ProviderInstanceConfigMap): boolean => + a === b || Equal.equals(Object.entries(a), Object.entries(b)); + const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); @@ -90,6 +112,7 @@ const withoutSecretReferences = ( const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecrets")(function* ( driver: AnyProviderDriver | undefined, entry: ProviderInstanceConfig, + secretProviders: RegistrySnapshot["secretProviders"], ) { const variables = entry.environment ?? []; const configFields = configSecretReferences(driver, entry); @@ -98,7 +121,9 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr ...configFields.map(({ reference }) => reference), ]; const values = - references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); + references.length === 0 + ? [] + : yield* (yield* SecretResolver.SecretResolver).read(references, secretProviders); const unreadable: Array = []; let index = 0; const environment: ResolvedProviderInstanceEnvironment = variables.flatMap((variable) => { @@ -110,12 +135,12 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr } return [{ ...variable, value: secret, sensitive: true }]; }); - const config: Record = {}; - for (const { field } of configFields) { + const config = configFields.flatMap(({ field, key }) => { const secret = values[index++]; - if (secret === undefined) unreadable.push(field); - else config[field] = secret; - } + if (secret !== undefined) return [{ field, key, value: secret }]; + unreadable.push(field); + return []; + }); const secrets: InstanceSecrets = { environment, config }; return { secrets, unreadable }; }); @@ -148,13 +173,22 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* { - const { driversById, parentScope, instanceId, rawInstanceId, entry, deferSecrets } = input; + const { + driversById, + parentScope, + instanceId, + rawInstanceId, + entry, + deferSecrets, + secretProviders, + } = input; const driver = driversById.get(entry.driver); if (!driver) { return { @@ -206,9 +240,9 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; }): (( - configMap: ProviderInstanceConfigMap, + snapshot: RegistrySnapshot, rebuild?: ReadonlySet, deferSecrets?: boolean, ) => Effect.Effect) => { const { state, driversById, parentScope } = input; - return (configMap, rebuild = new Set(), deferSecrets = false) => + return ({ configMap, secretProviders }, rebuild = new Set(), deferSecrets = false) => Effect.gen(function* () { const previousEntries = yield* Ref.get(state.entries); const previousUnavailable = yield* Ref.get(state.unavailable); @@ -330,7 +364,7 @@ const makeReconcile = (input: { }), ).pipe(Effect.map((perEntry) => perEntry.flat())); if (references.length > 0 && !deferSecrets) { - yield* (yield* SecretResolver.SecretResolver).read(references); + yield* (yield* SecretResolver.SecretResolver).read(references, secretProviders); } const builtEntries = new Map(); @@ -356,6 +390,7 @@ const makeReconcile = (input: { rawInstanceId, entry, deferSecrets, + secretProviders, }); if (result.kind === "live") { builtEntries.set(instanceId, result.live); @@ -399,10 +434,7 @@ const makeReconcile = (input: { export const makeProviderInstanceRegistry = Effect.fn( "ProviderInstanceRegistry.makeProviderInstanceRegistry", -)(function* (input: { - readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; -}): Effect.fn.Return< +)(function* (input: RegistryInput): Effect.fn.Return< { readonly registry: ProviderInstanceRegistry["Service"]; readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; @@ -429,49 +461,61 @@ export const makeProviderInstanceRegistry = Effect.fn( const state: RegistryState = { entries, unavailable, changes }; const reconcileWithR = makeReconcile({ state, driversById, parentScope }); - const configMapRef = yield* Ref.make(input.configMap); + const seed: RegistrySnapshot = + "snapshot" in input ? input.snapshot : { configMap: input.configMap }; + const readCurrentSnapshot = "snapshot" in input ? input.readCurrentSnapshot : undefined; + const snapshotRef = yield* Ref.make(seed); const semaphore = yield* Semaphore.make(1); + const applySnapshot = Effect.fn("ProviderInstanceRegistry.applySnapshot")( + (snapshot: RegistrySnapshot, rebuild?: ReadonlySet) => + Ref.set(snapshotRef, snapshot).pipe(Effect.andThen(reconcileWithR(snapshot, rebuild))), + ); + const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = (configMap) => semaphore.withPermits(1)( - Ref.set(configMapRef, configMap).pipe( - Effect.andThen(reconcileWithR(configMap)), - Effect.provideContext(driverContext), - ), + ( + readCurrentSnapshot ?? + (configMap === undefined ? Ref.get(snapshotRef) : Effect.succeed({ configMap })) + ).pipe(Effect.flatMap(applySnapshot), Effect.provideContext(driverContext)), ); - // Rebuilds an instance only when it was unavailable or its secrets changed, so a refresh does - // not restart providers whose credentials are the same. const reloadSecrets: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reloadSecrets"] = Effect.fn("ProviderInstanceRegistry.reloadSecrets")( function* (instanceId) { - const configMap = yield* Ref.get(configMapRef); + const previous = yield* Ref.get(snapshotRef); + const snapshot = readCurrentSnapshot === undefined ? previous : yield* readCurrentSnapshot; + const { configMap, secretProviders } = snapshot; const targets = yield* Effect.filter(Object.entries(configMap), ([rawInstanceId, entry]) => (instanceId === undefined || rawInstanceId === instanceId) && secretReferencesOf(driversById, entry).length > 0 ? isEntryEnabled(driversById, entry) : Effect.succeed(false), ); - if (targets.length === 0) return; + if (targets.length === 0) { + if (!configMapsEqual(previous.configMap, configMap)) yield* applySnapshot(snapshot); + return; + } const resolver = yield* SecretResolver.SecretResolver; const references = targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry)); yield* resolver.invalidate(references); - yield* resolver.read(references); + yield* resolver.read(references, secretProviders); const live = yield* Ref.get(entries); const rebuild = new Set(); for (const [rawInstanceId, entry] of targets) { const current = live.get(ProviderInstanceId.make(rawInstanceId)); - if (current === undefined) continue; + if (current === undefined || !entryEqual(current.entry, entry)) continue; const { secrets, unreadable } = yield* readInstanceSecrets( driversById.get(entry.driver), entry, + secretProviders, ); // A failed re-read keeps the working instance and the value it already holds. if (unreadable.length === 0 && !Equal.equals(secrets, current.secrets)) { rebuild.add(current.instance.instanceId); } } - yield* reconcileWithR(configMap, rebuild); + yield* applySnapshot(snapshot, rebuild); }, (effect) => semaphore.withPermits(1)(effect), Effect.provideContext(driverContext), @@ -479,12 +523,9 @@ export const makeProviderInstanceRegistry = Effect.fn( // Reading secrets can wait on a vault unlock, so the server does not wait for it to start. yield* semaphore.withPermits(1)( - Ref.set(configMapRef, input.configMap).pipe( - Effect.andThen(reconcileWithR(input.configMap, new Set(), true)), - Effect.provideContext(driverContext), - ), + reconcileWithR(seed, new Set(), true).pipe(Effect.provideContext(driverContext)), ); - const startupReferences = yield* Effect.filter(Object.entries(input.configMap), ([, entry]) => + const startupReferences = yield* Effect.filter(Object.entries(seed.configMap), ([, entry]) => secretReferencesOf(driversById, entry).length > 0 ? isEntryEnabled(driversById, entry) : Effect.succeed(false), @@ -492,17 +533,30 @@ export const makeProviderInstanceRegistry = Effect.fn( Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry))), ); if (startupReferences.length > 0) { - // The read waits outside the lock so other mutations are not held for a vault prompt; a - // configuration saved meanwhile has already reconciled itself. yield* Effect.gen(function* () { - yield* (yield* SecretResolver.SecretResolver).read(startupReferences); + yield* (yield* SecretResolver.SecretResolver).read(startupReferences, seed.secretProviders); yield* semaphore.withPermits(1)( Effect.gen(function* () { - const current = yield* Ref.get(configMapRef); - if (current === input.configMap) yield* reconcileWithR(current); + if ((yield* Ref.get(snapshotRef)) !== seed) return; + // A save can commit before the settings watcher acquires the registry permit. + const current = readCurrentSnapshot === undefined ? seed : yield* readCurrentSnapshot; + if ( + configMapsEqual(seed.configMap, current.configMap) && + Equal.equals(seed.secretProviders, current.secretProviders) + ) { + yield* reconcileWithR(seed); + } }), ); - }).pipe(Effect.provideContext(driverContext), Effect.forkScoped); + }).pipe( + Effect.catch((error) => + Effect.logError("ProviderInstanceRegistry startup settings read failed", { + operation: error.operation, + }), + ), + Effect.provideContext(driverContext), + Effect.forkScoped, + ); } const registry: ProviderInstanceRegistry["Service"] = { @@ -532,10 +586,9 @@ export const makeProviderInstanceRegistry = Effect.fn( return { registry, mutator }; }); -export const layer = (input: { - readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; -}): Layer.Layer< +export const layer = ( + input: RegistryInput, +): Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, R | SecretResolver.SecretResolver diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 21e7a5ac47fdf..5af47f2564132 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -121,19 +121,15 @@ const layerSettingsWatcher = (settingsChanges: Stream.Stream) => Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; - const serverSettings = yield* Settings.ServerSettingsService; yield* settingsChanges.pipe( Stream.runForEach(() => - // The subscription predates the seed read, so a queued change can be - // older than the seed. A failed current read must leave the registry intact. - serverSettings.getSettings.pipe( - Effect.flatMap((current) => - mutator.reconcile(deriveProviderInstanceConfigMap(current)), + mutator + .reconcile() + .pipe( + Effect.catchCause((cause) => + Effect.logError("ProviderInstanceRegistry reconcile failed", cause), + ), ), - Effect.catchCause((cause) => - Effect.logError("ProviderInstanceRegistry reconcile failed", cause), - ), - ), ), Effect.forkScoped, ); @@ -153,17 +149,24 @@ export const layer: Layer.Layer< ); // Subscribe before the seed read so saves during registry construction are queued. const settingsChanges = yield* serverSettings.subscribeChanges; - const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( - Effect.orElseSucceed(() => undefined), + const readCurrentSnapshot = serverSettings.getSettings.pipe( + Effect.map((settings) => ({ + configMap: deriveProviderInstanceConfigMap(settings), + secretProviders: settings.secretProviders, + })), + ); + const snapshot = yield* readCurrentSnapshot.pipe( + Effect.catch((error) => + Effect.logError("ProviderInstanceRegistry seed settings read failed", { + operation: error.operation, + }).pipe(Effect.as({ configMap: {}, secretProviders: {} })), + ), ); - const initialConfigMap = - initialSettings === undefined - ? ({} as ProviderInstanceConfigMap) - : deriveProviderInstanceConfigMap(initialSettings); const layerMutable = ProviderInstanceRegistry.layer({ drivers: BUILT_IN_DRIVERS, - configMap: initialConfigMap, + snapshot, + readCurrentSnapshot, }).pipe( Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), Layer.provide(AcpRegistrySupport.layerFromHost), diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index 35eb52d3f2906..835cb1d586ece 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,12 +1,21 @@ /** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ -import type { ProviderInstanceConfigMap, ProviderInstanceId } from "@t3tools/contracts"; +import type { + ProviderInstanceConfigMap, + ProviderInstanceId, + ServerSettingsError, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; export interface ProviderInstanceRegistryMutatorShape { - readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; + /** Hydrated registries read current settings after acquiring the mutation permit. */ + readonly reconcile: ( + configMap?: ProviderInstanceConfigMap, + ) => Effect.Effect; /** Re-reads the secrets of every instance, or one, and rebuilds those whose secrets changed. */ - readonly reloadSecrets: (instanceId?: ProviderInstanceId) => Effect.Effect; + readonly reloadSecrets: ( + instanceId?: ProviderInstanceId, + ) => Effect.Effect; } export class ProviderInstanceRegistryMutator extends Context.Service< diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 468af348cb352..ea562da9528bf 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 11642, - "codexAdditionalContextBytes": 13362, - "mcpToolListJsonBytes": 49761 + "claudeSystemAppendBytes": 11716, + "codexAdditionalContextBytes": 13436, + "mcpToolListJsonBytes": 49842 } diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..0941c7392ff94 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -27,6 +27,12 @@ describe("scheduled task schedule calculation", () => { }); it("compares schedules structurally", () => { + expect( + isSameSchedule( + { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + { type: "once", runAt: "2026-11-01T08:00:00.000Z" }, + ), + ).toBe(true); expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), ).toBe(true); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..f8fb648aaaaa9 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -1,5 +1,6 @@ import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; const MINUTE_MS = 60_000; @@ -14,6 +15,13 @@ export function nextScheduledRunAt( from: DateTime.DateTime, ): DateTime.DateTime | null { if (schedule.type === "webhook") return null; + if (schedule.type === "once") { + const runAt = DateTime.make(schedule.runAt); + return Option.isSome(runAt) && + DateTime.toEpochMillis(runAt.value) > DateTime.toEpochMillis(from) + ? runAt.value + : null; + } if (schedule.type === "interval") { // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); @@ -48,6 +56,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul return b.type === "interval" && a.everyMs === b.everyMs; } if (a.type === "webhook") return b.type === "webhook"; + if (a.type === "once") return b.type === "once" && Date.parse(a.runAt) === Date.parse(b.runAt); if (b.type !== "fixed_time") return false; // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts index 4a1a58505c5cb..1582af292d4e8 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts @@ -4,6 +4,7 @@ import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import { afterEach, vi } from "vite-plus/test"; @@ -129,3 +130,254 @@ it.effect.each([ Effect.provide(SqlitePersistence.layerMemory), ), ); + +// Each launch takes two minutes, so a manual run can start before the run time and end after it. +const slowLaunchLayer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.merge( + dependencies, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => TestClock.adjust("2 minutes").pipe(Effect.andThen(Effect.die("synthetic"))), + }), + ), + ), +); + +it.effect("runs a one-time schedule once and rejects past times", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const decoded = yield* decodeInput({ + ...input, + schedule: { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + }); + const created = yield* service.upsert(decoded); + expect(created.task.schedule).toEqual({ type: "once", runAt: "2026-11-01T08:00:00.000Z" }); + expect(created.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + // A manual run that starts before the time keeps the scheduled run, even if it ends after. + const early = yield* service.runNow({ id: created.task.id }); + expect(early.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + const due = yield* service.runNow({ id: created.task.id }); + expect(due.task.nextRunAt).toBeNull(); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBeNull(); + + yield* service.setEnabled({ id: created.task.id, enabled: false }); + const resumed = yield* service + .setEnabled({ id: created.task.id, enabled: true }) + .pipe(Effect.flip); + expect(resumed.message).toBe("The run time has passed. Pick a time in the future."); + + const past = yield* service + .upsert({ ...decoded, commandId: undefined, title: "Late" }) + .pipe(Effect.flip); + expect(past.message).toBe("The run time has passed. Pick a time in the future."); + }).pipe(Effect.provide(slowLaunchLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +const ONCE = { type: "once", runAt: "2026-11-01T08:00:00Z" } as const; +/** A next run that only the simulated concurrent write produces. */ +const MOVED = "2030-01-01T00:00:00.000Z"; + +const onceInput = { + ...input, + commandId: "once-race", + schedule: { type: "once", runAt: "2026-11-01T08:00:00Z" }, +}; + +it.effect.each([ + { change: "a run completes", saved: ONCE, edited: ONCE, pause: false, expected: MOVED }, + { + change: "a run completes during a padded-time edit", + saved: { type: "fixed_time", timeOfDay: "9:00" }, + edited: { type: "fixed_time", timeOfDay: "09:00" }, + pause: false, + expected: MOVED, + }, + { + change: "another client pauses the task", + saved: ONCE, + edited: ONCE, + pause: true, + expected: "2026-11-01T08:00:00.000Z", + }, +] as const)( + "a title save keeps the stored next run only while nothing else changed: $change", + ({ saved, edited, pause, expected }) => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The project check runs between the save's read and write, so it stands in for + // a concurrent write in that gap. + const writeMidSave = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => + sql`UPDATE scheduled_tasks SET next_run_at = ${MOVED}, enabled = ${pause ? 0 : 1}`.pipe( + Effect.as(false), + Effect.orDie, + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, writeMidSave)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const worktree = { type: "worktree", baseRef: "main" }; + yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: saved, + workspaceStrategy: { type: "root" }, + }), + ); + const renamed = yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: edited, + workspaceStrategy: worktree, + title: "Renamed", + }), + ); + expect(renamed.task.enabled).toBe(true); + expect(renamed.task.nextRunAt).toBe(expected); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("keeps a one-time run after a restart interrupts an earlier manual run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + const sql = yield* SqlClient.SqlClient; + const created = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + return yield* service.upsert(yield* decodeInput(onceInput)); + }).pipe(Effect.provide(serviceLayer)); + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'running', last_run_at = '2026-11-01T07:59:00.000Z'`; + + yield* TestClock.setTime(Date.parse("2026-11-01T08:05:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const [task] = (yield* service.list()).tasks; + expect(task?.id).toBe(created.task.id); + expect(task?.lastRunStatus).toBe("failed"); + expect(task?.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + }).pipe(Effect.provide(serviceLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("reports the stored task when the row was replaced during the run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The launch stands in for a delete and same-id recreate while the run is out. + const replaceRowDuringLaunch = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL, run_count = 0`.pipe( + Effect.orDie, + Effect.andThen(Effect.die("synthetic dispatch failure")), + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, replaceRowDuringLaunch)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const created = yield* service.upsert(yield* decodeInput(onceInput)); + const ran = yield* service.runNow({ id: created.task.id }); + expect(ran.task.runCount).toBe(0); + expect(ran.task.lastRunStatus).toBe("never"); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("catches up one enabled one-time run after downtime", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + let tick = Effect.void; + const capture = Layer.succeed(Scheduler.Scheduler, { + register: (name, run) => + Effect.context>().pipe( + Effect.map((context) => { + if (name === "scheduled-tasks") + tick = run.pipe(Effect.provideContext(context), Effect.orDie); + }), + ), + }); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, capture)), + ); + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00Z")); + const id = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const create = (commandId: string) => + decodeInput({ ...onceInput, commandId }).pipe( + Effect.flatMap(service.upsert), + Effect.map(({ task }) => task.id), + ); + const due = yield* create("due"); + const paused = yield* create("paused"); + const deleted = yield* create("deleted"); + const legacy = yield* create("legacy-interval"); + yield* sql`UPDATE scheduled_tasks + SET schedule_json = '{"type":"interval","everyMs":3600000}', + next_run_at = '2026-11-03T10:00:00.000Z' + WHERE task_id = ${legacy}`; + yield* service.setEnabled({ id: due, enabled: false }); + expect((yield* service.setEnabled({ id: due, enabled: true })).task.nextRunAt).toBe( + "2026-11-01T08:00:00.000Z", + ); + yield* service.setEnabled({ id: paused, enabled: false }); + yield* service.delete({ id: deleted }); + return due; + }).pipe(Effect.provide(layer)); + + yield* TestClock.setTime(Date.parse("2026-11-03T09:00:00Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + yield* tick; + yield* tick; + const { tasks } = yield* service.list(); + expect(tasks).toHaveLength(3); + expect(tasks.find((task) => task.id === id)).toMatchObject({ + lastRunAt: "2026-11-03T09:00:00.000Z", + lastRunStatus: "failed", + nextRunAt: null, + runCount: 1, + }); + expect(tasks.find((task) => !task.enabled)).toMatchObject({ + enabled: false, + nextRunAt: null, + runCount: 0, + }); + expect(tasks.find((task) => task.schedule.type === "interval")).toMatchObject({ + schedule: { type: "interval", everyMs: 3_600_000 }, + nextRunAt: "2026-11-03T10:00:00.000Z", + runCount: 0, + }); + expect(yield* sql`SELECT SUM(run_count) AS runs FROM scheduled_tasks`).toEqual([{ runs: 1 }]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index a360b4af69c9e..e3f5235751aa5 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -167,7 +167,7 @@ it.effect("keeps the task list order when a toggle or edit rewrites updated_at", yield* service.upsert(yield* decodeUpsertInput({ ...edit, enabled: false })); expect(yield* listedIds).toEqual(order); const sql = yield* SqlClient.SqlClient; - const futureSchedule = '{"type":"once","runAt":"2026-07-02T09:00:00.000Z"}'; + const futureSchedule = '{"type":"future_schedule","runAt":"2026-07-02T09:00:00.000Z"}'; yield* sql`UPDATE scheduled_tasks SET schedule_json = ${futureSchedule} WHERE task_id = ${order[0]}`; expect(yield* listedIds).toEqual([order[1]]); const rows = yield* sql<{ diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 2566960406c96..da39d01b42cf0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -301,6 +301,35 @@ function nextRunAt( } } +/** + * Next run after an interrupted run. A one-time task counts from the run's start + * (`lastRunAt`), so a manual run that began before its time does not use it up. + */ +function nextRunAfterRun( + task: Pick, + now: DateTime.DateTime, +): string | null { + const startedAt = + task.schedule.type === "once" && task.lastRunAt !== null + ? DateTime.make(task.lastRunAt) + : Option.none(); + return nextRunAt( + task, + Option.getOrElse(startedAt, () => now), + ); +} + +/** An enabled one-time task with a past time would never run, so saving it is an error. */ +function requireFutureOnce( + task: Pick, + now: DateTime.DateTime, + taskId: ScheduledTaskId, +) { + return task.enabled && task.schedule.type === "once" && nextRunAt(task, now) === null + ? taskError("The run time has passed. Pick a time in the future.", { taskId }) + : Effect.void; +} + function errorMessage(error: unknown): string { if (Cause.isCause(error)) return Cause.pretty(error); if (error instanceof Error) return error.message; @@ -477,7 +506,10 @@ export const layer = Layer.effect( for (const row of rows) { const decodedType = decodeScheduleType(row.schedule_json); const type = Option.isSome(decodedType) ? decodedType.value.type : undefined; - if (typeof type === "string" && !["interval", "fixed_time", "webhook"].includes(type)) { + if ( + typeof type === "string" && + !["interval", "fixed_time", "once", "webhook"].includes(type) + ) { unsupported++; } else { supported.push(row); @@ -561,6 +593,8 @@ export const layer = Layer.effect( const saveTask = ( task: ScheduledTask, requireExisting: boolean, + /** The schedule this save read, when it left the schedule unchanged. */ + keptSchedule: ScheduledTask["schedule"] | null, webhook: { readonly token: string | null; readonly secret: string | null; @@ -631,7 +665,18 @@ export const layer = Layer.effect( interaction_mode = excluded.interaction_mode, creation_source = excluded.creation_source, updated_at = excluded.updated_at, - next_run_at = excluded.next_run_at, + -- Keep a next_run_at that a finishing run moved during this save, unless + -- another edit changed the schedule or enabled state meanwhile. + next_run_at = CASE + WHEN ${keptSchedule === null ? 0 : 1} = 1 + AND scheduled_tasks.schedule_json IN ( + excluded.schedule_json, + ${keptSchedule === null ? null : JSON.stringify(keptSchedule)} + ) + AND scheduled_tasks.enabled = excluded.enabled + THEN scheduled_tasks.next_run_at + ELSE excluded.next_run_at + END, -- Only rotate changes a live token, so a save racing a rotation -- cannot bring the old URL back. webhook_token = CASE @@ -704,7 +749,9 @@ export const layer = Layer.effect( WHERE task_id = ${input.id} AND last_run_status = 'running' AND last_run_at = ${input.startedAtIso} + RETURNING task_id `.pipe( + Effect.map((rows) => rows.length > 0), Effect.mapError((cause) => taskError("Could not record schedule task run.", { taskId: input.id, cause }), ), @@ -714,7 +761,12 @@ export const layer = Layer.effect( // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { + function* ( + task: ScheduledTask, + startedAtIso: string, + message: string, + pendingDeliveryId?: string, + ) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -723,17 +775,17 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = ${message}, - next_run_at = ${nextRunAt(source, now)}, + next_run_at = ${nextRunAfterRun({ ...source, lastRunAt: startedAtIso }, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${pendingDeliveryId ?? null} AND state = 'running' ) THEN 0 ELSE 1 END - WHERE task_id = ${task.id} AND last_run_status = 'running' + WHERE task_id = ${task.id} AND last_run_status = 'running' AND last_run_at = ${startedAtIso} `; yield* notifyChanged; }, - (effect, task, _message, _pendingDeliveryId) => + (effect, task, _startedAtIso, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -753,6 +805,9 @@ export const layer = Layer.effect( readonly dispatched: Ref.Ref; }, ) { + // Read before reserving: nothing between the reservation and its release may fail. + const startedAt = yield* localNow; + const startedAtIso = iso(startedAt); const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; const next = new Set(active); @@ -763,13 +818,10 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task is already running.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } return yield* Effect.gen(function* () { - const startedAt = yield* localNow; - const startedAtIso = iso(startedAt); - // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { @@ -781,7 +833,7 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task not found.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } if ( webhook !== undefined && @@ -789,7 +841,11 @@ export const layer = Layer.effect( NodeSqliteClient.readOnly, )).length > 0 ) - return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; + return { + task: { ...active, lastRunStatus: "succeeded" as const }, + threadId: null, + dispatchFailed: false, + }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -798,7 +854,7 @@ export const layer = Layer.effect( Option.isNone(parsedNextRunAt) || DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) ) { - return { task: active, threadId: null }; + return { task: active, threadId: null, dispatchFailed: false }; } // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { @@ -914,18 +970,28 @@ export const layer = Layer.effect( const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; - const current = yield* findTask(task.id); - const scheduleSource = current ?? task; - const completed: ScheduledTask = { - ...scheduleSource, - updatedAt: iso(completedAt), - lastRunAt: startedAtIso, - nextRunAt: nextRunAt(scheduleSource, completedAt), - lastRunStatus, - lastRunError, - runCount: scheduleSource.runCount + 1, - }; - if (current !== null) { + // One transaction, so an edit cannot land between reading the schedule and + // writing the next run computed from it. The origin is read first: it reads + // files, and the transaction holds SQLite's writer lock. + const origin = yield* readWebhookOrigin; + const record = Effect.gen(function* () { + const [row] = yield* getRows(task.id); + const current = row === undefined ? null : yield* decodeRow(row, origin); + const scheduleSource = current ?? task; + const completed: ScheduledTask = { + ...scheduleSource, + updatedAt: iso(completedAt), + lastRunAt: startedAtIso, + // A manual run that starts before a one-time run must not use it up. + nextRunAt: nextRunAt( + scheduleSource, + scheduleSource.schedule.type === "once" ? startedAt : completedAt, + ), + lastRunStatus, + lastRunError, + runCount: scheduleSource.runCount + 1, + }; + if (current === null) return { recorded: completed, written: false }; // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. @@ -937,32 +1003,43 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); - if (webhook === undefined) yield* completion; - else - yield* sql - .withTransaction( - Effect.gen(function* () { - const pending = - yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; - if (pending.length === 0) yield* completion; - else { - const counted = - yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} - WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; - if (counted.length > 0) yield* completion; - } - }), - ) - .pipe( - Effect.mapError((cause) => - taskError("Could not record webhook completion.", { taskId: task.id, cause }), - ), - ); - yield* notifyChanged; - } - return { task: completed, threadId }; + let written = false; + if (webhook === undefined) written = yield* completion; + else { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) written = yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) written = yield* completion; + } + } + // A row recreated mid-run is not this run's, so report what is stored. + return { recorded: written ? completed : current, written }; + }); + const { recorded, written } = yield* sql + .withTransaction(record) + .pipe( + Effect.mapError((cause) => + isScheduledTaskError(cause) + ? cause + : taskError( + webhook === undefined + ? "Could not record schedule task run." + : "Could not record webhook completion.", + { taskId: task.id, cause }, + ), + ), + ); + if (written) yield* notifyChanged; + // The stored row can belong to a replacement, so report this dispatch separately. + return { task: recorded, threadId, dispatchFailed: !runSucceeded }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), + Effect.onError((cause) => + releaseStuckRun(task, startedAtIso, errorMessage(cause), webhook?.deliveryId), + ), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -1058,7 +1135,7 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', - next_run_at = ${nextRunAt(decoded.success, now)}, + next_run_at = ${nextRunAfterRun(decoded.success, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending @@ -1169,7 +1246,9 @@ export const layer = Layer.effect( : undefined), ), } - : input.schedule; + : input.schedule.type === "once" + ? { type: "once", runAt: iso(DateTime.makeUnsafe(input.schedule.runAt)) } + : input.schedule; const webhook = input.schedule.type === "webhook" ? yield* Effect.gen(function* () { @@ -1215,6 +1294,8 @@ export const layer = Layer.effect( existingTask !== null && existingTask.enabled === input.enabled && isSameSchedule(existingTask.schedule, schedule); + if (!scheduleUnchanged) + yield* requireFutureOnce({ enabled: input.enabled, schedule }, now, id); const task: ScheduledTask = { id, title: input.title, @@ -1239,7 +1320,12 @@ export const layer = Layer.effect( lastRunError: existingTask?.lastRunError ?? null, runCount: existingTask?.runCount ?? 0, }; - yield* saveTask(task, input.requireExisting === true, webhook); + yield* saveTask( + task, + input.requireExisting === true, + scheduleUnchanged ? existingTask.schedule : null, + webhook, + ); yield* notifyChanged; return { task: (yield* findTask(id)) ?? task }; }); @@ -1250,6 +1336,11 @@ export const layer = Layer.effect( const existing = yield* loadTask(input.id); if (existing.enabled === input.enabled) return { task: existing }; const now = yield* localNow; + yield* requireFutureOnce( + { enabled: input.enabled, schedule: existing.schedule }, + now, + input.id, + ); const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); // RETURNING so a task deleted between the load and this UPDATE is a // visible not-found error, not a false success. @@ -1687,8 +1778,8 @@ export const layer = Layer.effect( ]); const dispatched = yield* Ref.make(false); return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" + Effect.flatMap(({ dispatchFailed }) => + dispatchFailed ? runOutcome("failed").pipe( Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), Effect.andThen(releaseFailedDelivery(deliveryId)), diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index cdd4e60e62d76..3bc3fe506e78f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -1199,6 +1199,32 @@ it.effect("start failures release both replay keys and relay claims for redelive ), ); +it.effect("a start failure releases the relay claim when the task row is replaced mid-run", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const request = requestFor(task, { relayDeliveryId: "replaced-mid-run" }); + const first = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ).pipe(Effect.forkChild); + yield* Queue.take(launches); + // A same-id recreate while the launch is out: the stored row is no longer this run's. + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL`; + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + const retry = yield* service.triggerWebhook(request); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate, failLaunches: { remaining: 1 } }, + ); + }), +); + it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => withService(({ service, sql, launches }) => Effect.gen(function* () { diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index b17458e2d8f13..968e0562b9889 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,20 +1,32 @@ -import { secretReferencesInSettings } from "@t3tools/contracts"; +import { + secretReferencesInSettings, + withSettingsSecrets, + type ServerSettings, +} from "@t3tools/contracts"; import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; /** - * Replaces each reference in the fields `schema` declares as `SettingsSecretValue` with the secret + * Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret * it names, read in one batch. An unreadable secret comes back as `""`, so it behaves as unset. */ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(function* < Settings extends Readonly>, ->(resolver: SecretResolver.SecretResolver["Service"], schema: Schema.Top, settings: Settings) { +>( + resolver: SecretResolver.SecretResolver["Service"], + schema: Schema.Top, + settings: Settings, + providersSnapshot: ServerSettings["secretProviders"], +) { const pending = secretReferencesInSettings(schema, settings); if (pending.length === 0) return settings; - const values = yield* resolver.read(pending.map(({ reference }) => reference)); - const resolved = Object.fromEntries( - pending.map(({ field }, index) => [field, values[index] ?? ""] as const), + const values = yield* resolver.read( + pending.map(({ reference }) => reference), + providersSnapshot, + ); + return withSettingsSecrets( + settings, + pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); - return { ...settings, ...resolved }; }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index c31893fea1253..b91db5c4b251d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -10,13 +10,16 @@ import { resolveProviderInstanceEnabled, SecretProviderId, ServerSettings, + ServerSettingsError, ServerSettingsPatch, UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -24,6 +27,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -32,12 +36,14 @@ import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { SECRET_REDACTED } from "./settingsSecrets.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); const decodeAccountRoutingPolicy = Schema.decodeUnknownEffect(AccountRoutingPolicy); const decodeServerSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); +const encodeServerSettingsError = Schema.encodeEffect(ServerSettingsError); const layerServerSettings = () => ServerSettingsModule.layer.pipe( @@ -65,18 +71,6 @@ const layerServerSettingsWithSecrets = () => ), ); -const layerFailingSecretStore = (cause: ServerSecretStore.SecretStoreError) => - Layer.succeed( - ServerSecretStore.ServerSecretStore, - ServerSecretStore.ServerSecretStore.of({ - get: () => Effect.fail(cause), - set: () => Effect.void, - create: () => Effect.void, - getOrCreateRandom: () => Effect.succeed(new Uint8Array()), - remove: () => Effect.void, - }), - ); - const recordProviderUsage = (provider: string, instanceId: string | null = provider) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -99,9 +93,12 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi }); it.layer(NodeServices.layer)("server settings", (it) => { - it.effect.each(["github.com", " GitHub.COM "])( - "preserves future credential references and private routing through older settings saves (%s)", - (host) => + it.effect.each([ + ["github.com", false], + [" GitHub.COM ", true], + ] as const)( + "loads credential references and private routing without materializing stale secrets (%s)", + ([host, padded]) => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; const config = yield* ServerConfig.ServerConfig; @@ -109,8 +106,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const store = yield* ServerSecretStore.ServerSecretStore; const sourceId = UsageLimitSourceId.make("future-hub"); const instanceId = ProviderInstanceId.make("private-local"); - const reference = { provider: "personal", reference: "op://Private/GitHub/token" }; - const usageReference = { provider: "personal", reference: "op://Private/Hub/key" }; + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; + const usageReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Hub/key", + }; const providerInstances = { [instanceId]: { driver: ProviderDriverKind.make("codex"), @@ -130,7 +133,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { include: [{ provider: "codex", model: "sol" }], exclude: [{ model: "legacy" }], }, - sourceControlHosts: { github: { tokens: { [host]: reference } } }, + sourceControlHosts: { + github: { + tokens: { + [host]: padded ? { ...reference, provider: " personal " } : reference, + }, + }, + }, usageLimitSources: { [sourceId]: { kind: "cliproxy", @@ -157,8 +166,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.equal(loaded.modelPrivacy, "private"); assert.deepEqual(loaded.privateModelRoute, privateModelRoute); assert.deepEqual(loaded.providerInstances[instanceId], providerInstances[instanceId]); - assert.deepEqual(loaded.sourceControlHosts.github?.tokens, { "github.com": "" }); - assert.equal(loaded.usageLimitSources[sourceId]?.managementKey, ""); + assert.deepEqual(loaded.sourceControlHosts.github?.tokens, { "github.com": reference }); + assert.deepEqual(loaded.usageLimitSources[sourceId]?.managementKey, usageReference); assert.notInclude(JSON.stringify(loaded), "stale-secret"); assert.equal((yield* service.getRoutingSettings).modelPrivacy, "private"); const saved = yield* service.updateSettings({ @@ -177,9 +186,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(disk.usageLimitSources, fixture.usageLimitSources); assert.deepEqual(disk.usageModelFilter, fixture.usageModelFilter); for (const tokens of [ - { "github.com": "replacement" }, { "github.com": "", " GitHub.COM ": "replacement" }, - { "github.com": "", " GitHub.COM ": "••••••" }, + { "github.com": "", " GitHub.COM ": SECRET_REDACTED }, + { "github.com": { provider: 42, reference: "path" } }, + ["invalid-array"], + { " ": "invalid-host" }, ]) { const rejected = yield* Effect.flip( service.updateSettings({ @@ -206,31 +217,126 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); - it.effect.each([true, false])( - "rejects ambiguous saved credential hosts without rewriting references (alias first: %s)", - (aliasFirst) => + it.effect.each(["host", "usage"] as const)( + "discards a delayed legacy marker for a removed %s credential", + (slot) => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const store = yield* ServerSecretStore.ServerSecretStore; + const hub = UsageLimitSourceId.make("hub"); + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Settings/token", + }; + const source = { + kind: "cliproxy", + url: "https://hub.test", + enabled: true, + managementKey: reference, + }; + const secretName = + slot === "host" + ? sourceControlHostSecretName({ + kind: "github", + field: "tokens", + serverHost: "github.com", + }) + : "usage-limit-source-aHVi"; + yield* store.set(secretName, new TextEncoder().encode("stale-secret")); + using reads = vi.spyOn(store, "get"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify( + slot === "host" + ? { sourceControlHosts: { github: { tokens: { "github.com": reference } } } } + : { usageLimitSources: { [hub]: source } }, + ), + ); + const legacy = ServerSettingsModule.redactServerSettingsForClient( + yield* service.getSettings, + { + secretReferences: false, + }, + ); + const patch = + slot === "host" + ? { sourceControlHosts: legacy.sourceControlHosts } + : { usageLimitSources: legacy.usageLimitSources }; + yield* service.updateSettings( + slot === "host" + ? { sourceControlHosts: { github: { tokens: {} } } } + : { usageLimitSources: { [hub]: null } }, + ); + assert.deepEqual( + reads.mock.calls.filter(([name]) => name === secretName), + [], + ); + + for (const currentSlot of ["absent", "empty"]) { + const replayed = yield* service.updateSettings(patch); + assert.equal( + slot === "host" + ? ( + replayed.sourceControlHosts.github?.tokens as Record | undefined + )?.["github.com"] + : replayed.usageLimitSources[hub]?.managementKey, + "", + currentSlot, + ); + assert.notInclude(yield* fs.readFileString(config.settingsPath), SECRET_REDACTED); + assert.deepEqual( + reads.mock.calls.filter(([name]) => name === secretName), + [], + ); + } + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect.each([ + "alias first", + "alias last", + "invalid entry", + "invalid map", + "retired tokens", + "sign-ins", + "retired sign-ins", + ] as const)( + "rejects malformed saved credential hosts without rewriting settings (%s)", + (fixture) => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const store = yield* ServerSecretStore.ServerSecretStore; + const fixtureToken = "fixture-rejected-api-key"; const entries = [ [" GitHub.COM ", { provider: "personal", reference: "op://Private/First/token" }], ["github.com", { provider: "personal", reference: "op://Private/Second/token" }], ]; + const tokens = + fixture === "invalid entry" + ? { "github.com": { provider: 42, reference: "path" } } + : fixture === "invalid map" + ? fixtureToken + : fixture === "retired tokens" + ? { "github.com": SECRET_REDACTED, " GitHub.COM ": "inline-token" } + : Object.fromEntries(fixture === "alias first" ? entries : entries.toReversed()); + const github = + fixture === "sign-ins" || fixture === "retired sign-ins" + ? { + signIns: { "github.com": "session-a", " GitHub.COM ": "session-b" }, + tokens: { "github.com": SECRET_REDACTED }, + } + : { tokens }; const raw = JSON.stringify({ modelPrivacy: "private", - sourceControlHosts: { - github: { tokens: Object.fromEntries(aliasFirst ? entries : entries.toReversed()) }, - }, + ...(fixture.startsWith("retired") ? { github } : { sourceControlHosts: { github } }), }); - const secretName = sourceControlHostSecretName({ - kind: "github", - field: "tokens", - serverHost: "github.com", - }); - yield* store.set(secretName, new TextEncoder().encode("stale-secret")); using reads = vi.spyOn(store, "get"); + using writes = vi.spyOn(store, "set"); + using removes = vi.spyOn(store, "remove"); yield* fs.writeFileString(config.settingsPath, raw); for (const operation of [ service.getSettings, @@ -239,15 +345,283 @@ it.layer(NodeServices.layer)("server settings", (it) => { ]) { const rejected = yield* Effect.flip(operation); assert.equal(rejected.operation, "unsupported-secret-reference"); + if (fixture === "invalid map") { + const formatted = Cause.pretty(Cause.fail(rejected)); + const loggableCause = String(rejected.cause); + const wire = JSON.stringify(yield* encodeServerSettingsError(rejected)); + for (const message of [formatted, loggableCause, wire]) { + assert.isFalse(message.includes(fixtureToken)); + } + assert.include(loggableCause, "tokens"); + assert.include(loggableCause, "map"); + } + assert.equal(yield* fs.readFileString(config.settingsPath), raw); + } + assert.deepEqual(reads.mock.calls, []); + assert.deepEqual(writes.mock.calls, []); + assert.deepEqual(removes.mock.calls, []); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect.each(["reference", "literal"] as const)( + "rejects a stale usage source marker for an old URL (%s)", + (kind) => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const store = yield* ServerSecretStore.ServerSecretStore; + const hub = UsageLimitSourceId.make("hub"); + const oldSource = { + kind: "cliproxy", + url: "https://old-hub.test", + managementKey: "fixture-old-key", + enabled: true, + } as const; + const initial = yield* service.updateSettings({ usageLimitSources: { [hub]: oldSource } }); + const oldClient = ServerSettingsModule.redactServerSettingsForClient(initial, { + secretReferences: false, + }).usageLimitSources[hub]!; + const newKey = + kind === "reference" + ? { provider: SecretProviderId.make("personal"), reference: "op://Private/New/key" } + : "fixture-new-key"; + const current = { + ...oldSource, + url: "https://new-hub.test", + managementKey: newKey, + }; + yield* service.updateSettings({ usageLimitSources: { [hub]: current } }); + const raw = JSON.stringify({ + ...JSON.parse(yield* fs.readFileString(config.settingsPath)), + bitbucket: { accessToken: SECRET_REDACTED }, + }); + yield* store.set("bitbucket-access-token", new TextEncoder().encode("fixture-legacy-key")); + yield* fs.writeFileString(config.settingsPath, raw); + const changes = yield* service.subscribeChanges; + using reads = vi.spyOn(store, "get"); + using writes = vi.spyOn(store, "set"); + using removes = vi.spyOn(store, "remove"); + + for (const managementKey of [oldClient.managementKey, ""]) { + const rejected = yield* Effect.flip( + service.updateSettings({ + usageLimitSources: { [hub]: { ...oldClient, managementKey } }, + }), + ); + assert.equal(rejected.operation, "unsupported-secret-reference"); assert.equal(yield* fs.readFileString(config.settingsPath), raw); } + assert.deepEqual(reads.mock.calls, []); + assert.deepEqual(writes.mock.calls, []); + assert.deepEqual(removes.mock.calls, []); + const edited = { ...current, label: "New label", enabled: false }; + const saved = yield* service.updateSettings({ + usageLimitSources: { [hub]: { ...edited, managementKey: SECRET_REDACTED } }, + }); + assert.deepEqual(saved.usageLimitSources[hub], edited); assert.deepEqual( - reads.mock.calls.filter(([name]) => name === secretName), - [], + Option.getOrThrow(yield* Stream.runHead(changes)).usageLimitSources[hub], + edited, ); + + const replaced = yield* service.updateSettings({ + usageLimitSources: { [hub]: { ...oldSource, managementKey: "fixture-replacement-key" } }, + }); + assert.deepEqual(replaced.usageLimitSources[hub], { + ...oldSource, + managementKey: "fixture-replacement-key", + }); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); + it.effect.each(["writer", "reader"] as const)( + "serializes cold settings migration with a usage source change (%s first)", + (first) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const firstRead = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const releaseSecond = yield* Deferred.make(); + const hub = UsageLimitSourceId.make("hub"); + const oldSource = { + kind: "cliproxy", + url: "https://old-hub.test", + managementKey: "fixture-old-key", + enabled: true, + } as const; + const newSource = { + ...oldSource, + url: "https://new-hub.test", + managementKey: "fixture-new-key", + }; + const raw = JSON.stringify({ + projectAutoPullOverrides: { "legacy-project": true }, + usageLimitSources: { [hub]: { ...oldSource, managementKey: SECRET_REDACTED } }, + }); + let settingsPath: string | undefined; + let captureReads = true; + let capturedReads = 0; + const gatedFileSystem = FileSystem.FileSystem.of({ + ...fs, + exists: (path) => + Effect.suspend(() => (path === settingsPath ? Effect.succeed(true) : fs.exists(path))), + readFileString: (path, encoding) => + Effect.suspend(() => { + if (path !== settingsPath || !captureReads) return fs.readFileString(path, encoding); + capturedReads++; + return ( + capturedReads === 1 + ? Deferred.succeed(firstRead, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ) + : Deferred.await(releaseSecond) + ).pipe(Effect.as(raw)); + }), + }); + + yield* Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const store = yield* ServerSecretStore.ServerSecretStore; + settingsPath = config.settingsPath; + yield* fs.writeFileString(settingsPath, raw); + yield* store.set( + `usage-limit-source-${Buffer.from(hub).toString("base64url")}`, + new TextEncoder().encode(oldSource.managementKey), + ); + + const write = service.updateSettings({ usageLimitSources: { [hub]: newSource } }); + const read = service.getSettings; + const fork = Effect.forkChild({ startImmediately: true }); + const firstFiber = yield* (first === "writer" ? write : read).pipe(fork); + yield* Deferred.await(firstRead); + const secondFiber = yield* (first === "writer" ? read : write).pipe(fork); + captureReads = false; + yield* Deferred.succeed(releaseFirst, undefined); + const firstResult = yield* Fiber.join(firstFiber); + yield* Deferred.succeed(releaseSecond, undefined); + const secondResult = yield* Fiber.join(secondFiber); + + assert.deepEqual( + firstResult.usageLimitSources[hub], + first === "writer" ? newSource : oldSource, + ); + const persisted = yield* decodeServerSettingsJson(yield* fs.readFileString(settingsPath)); + assert.deepEqual( + { + second: secondResult.usageLimitSources[hub], + persisted: persisted.usageLimitSources[hub], + }, + { second: newSource, persisted: { ...newSource, managementKey: SECRET_REDACTED } }, + ); + assert.equal(capturedReads, 1); + }).pipe( + Effect.provide( + layerServerSettingsWithSecrets().pipe( + Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, gatedFileSystem)), + ), + ), + ); + }), + ); + + it.effect.each(["inline", "retired"] as const)( + "finishes a canceled cold %s migration before a settings writer can replace its token", + (entry) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const renameStarted = yield* Deferred.make(); + const finishRename = yield* Deferred.make(); + const renameFinished = yield* Deferred.make(); + const secretName = sourceControlHostSecretName({ + kind: "bitbucket", + field: "accessToken", + serverHost: null, + }); + const retiredName = "bitbucket-access-token"; + let destination: string | undefined; + let paused = false; + const gatedFileSystem = FileSystem.FileSystem.of({ + ...fs, + rename: (from, to) => + Effect.gen(function* () { + if (to !== destination || paused) return yield* fs.rename(from, to); + paused = true; + // The dispatched rename outlives cancellation of the fiber awaiting it. + const rename = yield* Deferred.succeed(renameStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRename)), + Effect.andThen(fs.rename(from, to)), + Effect.tap(() => Deferred.succeed(renameFinished, undefined)), + Effect.forkIn(scope), + ); + yield* Fiber.join(rename); + }), + }); + + yield* Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const { settingsPath, secretsDir } = yield* ServerConfig.ServerConfig; + const store = yield* ServerSecretStore.ServerSecretStore; + destination = path.join(secretsDir, `${secretName}.bin`); + const oldToken = "fixture-old-token"; + const newToken = "fixture-new-token"; + const bitbucket = { + email: "old@example.test", + accessToken: entry === "inline" ? oldToken : SECRET_REDACTED, + }; + if (entry === "retired") { + yield* store.set(retiredName, new TextEncoder().encode(oldToken)); + } + yield* fs.writeFileString( + settingsPath, + JSON.stringify( + entry === "inline" ? { sourceControlHosts: { bitbucket } } : { bitbucket }, + ), + ); + + const fork = Effect.forkChild({ startImmediately: true }); + const reader = yield* service.getSettings.pipe(fork); + yield* Deferred.await(renameStarted); + const cancellation = yield* Fiber.interrupt(reader).pipe(fork); + const writer = yield* service + .updateSettings({ + sourceControlHosts: { + bitbucket: { email: "new@example.test", accessToken: newToken }, + }, + }) + .pipe(fork); + const canceledBeforeRename = cancellation.pollUnsafe() !== undefined; + if (canceledBeforeRename) yield* Fiber.join(writer); + yield* Deferred.succeed(finishRename, undefined); + yield* Deferred.await(renameFinished); + yield* Fiber.join(cancellation); + yield* Fiber.join(writer); + const persisted = yield* decodeServerSettingsJson(yield* fs.readFileString(settingsPath)); + const stored = yield* store.get(secretName); + assert.deepEqual( + { + email: persisted.sourceControlHosts.bitbucket?.email, + marker: persisted.sourceControlHosts.bitbucket?.accessToken, + token: Option.map(stored, (value) => new TextDecoder().decode(value)), + }, + { email: "new@example.test", marker: SECRET_REDACTED, token: Option.some(newToken) }, + ); + assert.isFalse(canceledBeforeRename); + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(reader))); + assert.isTrue(Option.isNone(yield* store.get(retiredName))); + }).pipe( + Effect.provide( + layerServerSettingsWithSecrets().pipe( + Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, gatedFileSystem)), + ), + ), + ); + }), + ); + it.effect("persists routing against implicit and atomically created accounts", () => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -474,7 +848,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); assert.deepStrictEqual( - ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true }) + .secretProviders, { personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, vault: { kind: "openbao", label: "Vault" }, @@ -755,12 +1130,30 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const changes = yield* serverSettings.subscribeChanges; + const hub = UsageLimitSourceId.make("hub"); + const firstSource = { + kind: "cliproxy", + url: "https://first-hub.test", + managementKey: "fixture-first-key", + enabled: true, + } as const; + const secondSource = { + ...firstSource, + url: "https://second-hub.test", + managementKey: "fixture-second-key", + }; + yield* serverSettings.updateSettings({ + usageLimitSources: { [hub]: firstSource }, + }); yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "~/next", + usageLimitSources: { [hub]: secondSource }, }); - const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); - assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next"); + const buffered = yield* changes.pipe(Stream.take(2), Stream.runCollect); + assert.deepEqual( + buffered.map((change) => change.usageLimitSources[hub]), + [firstSource, secondSource], + ); }), ).pipe(Effect.provide(layerServerSettings())), ); @@ -1524,7 +1917,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "quota-secret", "expo-secret", ]; - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); const disk = yield* fs.readFileString(config.settingsPath); for (const value of values) { assert.include(JSON.stringify(saved), value); @@ -1558,7 +1953,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, }); assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); - const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); + const redacted = ServerSettingsModule.redactServerSettingsForClient(changed, { + secretReferences: true, + }); assert.propertyVal( redacted.providerInstances[instanceId]?.config, "apiKey", @@ -1607,9 +2004,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ - instanceId - ]?.environment, + ServerSettingsModule.redactServerSettingsForClient(referenced, { secretReferences: true }) + .providerInstances[instanceId]?.environment, [{ name: "API_TOKEN", value: secret, sensitive: true }], ); yield* service.updateSettings({ @@ -1660,7 +2056,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "serverPassword", "legacy-password", ); - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); for (const value of ["legacy-api-key", "legacy-password", "admin_synthetic_billing_key"]) { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); @@ -1691,10 +2089,16 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; + const initialTokens = { + provider: "placeholder", + reference: "placeholder", + "github.com": "ghp_dotcom", + "ghe.acme.test": "ghp_ghe", + }; const saved = yield* serverSettings.updateSettings({ sourceControlHosts: { bitbucket: { email: "me@example.com", accessToken: "bb-access" }, - github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + github: { tokens: initialTokens }, }, }); assert.deepEqual(saved.sourceControlHosts.bitbucket, { @@ -1702,31 +2106,37 @@ it.layer(NodeServices.layer)("server settings", (it) => { accessToken: "bb-access", }); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret); + for (const secret of ["bb-access", "placeholder", "ghp_dotcom", "ghp_ghe"]) + assert.notInclude(raw, secret); assert.include(raw, "me@example.com"); - const forClient = - ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts; + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }).sourceControlHosts; assert.equal(forClient.bitbucket?.email, "me@example.com"); assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access"); assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0); + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(saved, { secretReferences: false }) + .sourceControlHosts.github?.tokens, + Object.fromEntries(Object.keys(initialTokens).map((host) => [host, SECRET_REDACTED])), + ); yield* serverSettings.updateSettings({ sourceControlHosts: forClient }); yield* serverSettings.updateSettings({ sourceControlHosts: { bitbucket: { email: "other@example.com" } }, }); const kept = (yield* serverSettings.getSettings).sourceControlHosts; assert.deepEqual(kept.bitbucket, { email: "other@example.com", accessToken: "bb-access" }); - assert.deepEqual(kept.github?.tokens, { - "github.com": "ghp_dotcom", - "ghe.acme.test": "ghp_ghe", - }); + assert.deepEqual(kept.github?.tokens, initialTokens); const cleared = yield* serverSettings.updateSettings({ sourceControlHosts: { bitbucket: { accessToken: "" }, github: { tokens: { + provider: "", + reference: "", "github.com": "", "ghe.acme.test": (forClient.github!.tokens as Record)[ "ghe.acme.test" @@ -1742,97 +2152,221 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + yield* serverSettings.updateSettings({ + sourceControlHosts: { github: { tokens: {} } }, + }); const reference = { provider: SecretProviderId.make("personal"), reference: "op://Private/Bitbucket/token", }; + const gitHubReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + sourceControlHosts: { + bitbucket: { accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "github.com"))); const referenced = yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: reference } }, + sourceControlHosts: { + bitbucket: { accessToken: reference }, + github: { tokens: { "github.com": gitHubReference, "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.deepEqual(referenced.sourceControlHosts.bitbucket?.accessToken, reference); assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); - assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts - .bitbucket?.accessToken, - reference, - ); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + const redacted = ServerSettingsModule.redactServerSettingsForClient(referenced, { + secretReferences: true, + }).sourceControlHosts; + assert.deepEqual(redacted.bitbucket?.accessToken, reference); + const tokens = redacted.github?.tokens as Record; + assert.deepEqual(tokens["github.com"], gitHubReference); + assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); + + const usageReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Hub/key", + }; + const hub = UsageLimitSourceId.make("hub"); + const withUsage = yield* serverSettings.updateSettings({ + usageLimitSources: { + [hub]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: usageReference, + enabled: true, + }, + }, + }); + const legacy = ServerSettingsModule.redactServerSettingsForClient(withUsage, { + secretReferences: false, + }); + assert.notInclude(JSON.stringify(legacy), "op://"); + assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED); + assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED); + const echoed = yield* serverSettings.updateSettings({ + sourceControlHosts: legacy.sourceControlHosts, + usageLimitSources: legacy.usageLimitSources, + }); + assert.deepEqual(echoed.sourceControlHosts.bitbucket?.accessToken, reference); + assert.deepEqual(echoed.sourceControlHosts.github?.tokens, { + "github.com": gitHubReference, + "ghe.acme.test": "ghp_ghe", + }); + assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); - it.effect("keeps the retired keys for another load when a token cannot be copied", () => { - const cause = new ServerSecretStore.SecretStoreReadError({ - resource: "bitbucket-access-token", - cause: PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: "bitbucket-access-token", - description: "Secret backend unavailable.", - }), - }); - const layerSettings = ServerSettingsModule.layer.pipe( - Layer.provide(layerFailingSecretStore(cause)), - Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), - Layer.provideMerge( - Layer.fresh( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-server-settings-legacy-failure-test-", + it.effect.each([false, true])( + "keeps the retired keys for another load when a token cannot be copied (project fold: %s)", + (withProjectOverride) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const retiredName = "bitbucket-access-token"; + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: retiredName, + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: retiredName, + description: "Secret backend unavailable.", }), - ), - ), - ); - return Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const legacy = JSON.stringify({ - bitbucket: { email: "me@example.com", accessToken: "\u2022\u2022\u2022\u2022\u2022\u2022" }, - }); - yield* fileSystem.writeFileString(serverConfig.settingsPath, legacy); - - yield* Effect.exit(serverSettings.getSettings); + }); + const legacy = JSON.stringify({ + bitbucket: { email: "me@example.com", accessToken: SECRET_REDACTED }, + ...(withProjectOverride ? { projectAutoPullOverrides: { "legacy-project": true } } : {}), + }); + yield* secrets.set(retiredName, new TextEncoder().encode("fixture-legacy-token")); + yield* fileSystem.writeFileString(serverConfig.settingsPath, legacy); + const get = secrets.get; + using reads = vi + .spyOn(secrets, "get") + .mockImplementation((name) => (name === retiredName ? Effect.fail(cause) : get(name))); - assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy); - }).pipe(Effect.provide(layerSettings)); - }); + assert.equal( + (yield* serverSettings.getSettings).projectSettingsFolded, + withProjectOverride, + ); + assert.equal( + (yield* serverSettings.getRoutingSettings).projectSettingsFolded, + withProjectOverride, + ); + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy); + const rejected = yield* Effect.flip( + serverSettings.updateSettings({ responseStreamingMode: "turn" }), + ); + assert.equal(rejected.operation, "write-file"); + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy); + + reads.mockRestore(); + const recovered = yield* serverSettings.updateSettings({ responseStreamingMode: "turn" }); + assert.equal(recovered.sourceControlHosts.bitbucket?.accessToken, "fixture-legacy-token"); + assert.equal(recovered.responseStreamingMode, "turn"); + assert.equal(recovered.projectSettingsFolded, withProjectOverride); + if (withProjectOverride) { + assert.isTrue( + recovered.projectSettingsOverrides[ProjectId.make("legacy-project")]?.defaultAutoPull, + ); + } + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.isUndefined(JSON.parse(raw).bitbucket); + assert.notInclude(raw, "fixture-legacy-token"); + assert.isTrue(Option.isNone(yield* secrets.get(retiredName))); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); - it.effect("moves the retired bitbucket and github keys and their secrets on load", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const secrets = yield* ServerSecretStore.ServerSecretStore; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const marker = "\u2022\u2022\u2022\u2022\u2022\u2022"; - yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("bb-saved")); - yield* secrets.set( - `github-token-${Buffer.from("github.com", "utf8").toString("base64url")}`, - new TextEncoder().encode("ghp_saved"), - ); - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - JSON.stringify({ - bitbucket: { email: "me@example.com", accessToken: marker }, - github: { - hosts: { "github.com": { account: "work" } }, - tokens: { "github.com": marker }, - }, - }), - ); + it.effect.each(["legacy", "canonical"] as const)( + "cleans up retired source control settings with %s GitHub tokens", + (githubSource) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const marker = "\u2022\u2022\u2022\u2022\u2022\u2022"; + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("bb-saved")); + const retiredName = `github-token-${Buffer.from("github.com", "utf8").toString("base64url")}`; + yield* secrets.set(retiredName, new TextEncoder().encode("ghp_saved")); + const canonicalGithub = { + hosts: { "github.com": { account: "canonical" } }, + tokens: { "github.com": "fixture-canonical-key" }, + }; + if (githubSource === "canonical") { + yield* secrets.set( + sourceControlHostSecretName({ + kind: "github", + field: "tokens", + serverHost: "github.com", + }), + new TextEncoder().encode(canonicalGithub.tokens["github.com"]), + ); + } + using reads = vi.spyOn(secrets, "get"); + using removes = vi.spyOn(secrets, "remove"); + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + JSON.stringify({ + ...(githubSource === "legacy" && { + bitbucket: { email: "me@example.com", accessToken: marker }, + }), + github: { + hosts: { "github.com": { account: "work" } }, + tokens: { + provider: "placeholder", + reference: "placeholder", + "github.com": marker, + ...(githubSource === "canonical" && { "GITHUB.COM": "fixture-retired-key" }), + }, + }, + ...(githubSource === "canonical" && { + sourceControlHosts: { + github: { ...canonicalGithub, tokens: { "github.com": marker } }, + }, + }), + }), + ); - const loaded = (yield* serverSettings.getSettings).sourceControlHosts; + const loaded = (yield* serverSettings.getSettings).sourceControlHosts; - assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" }); - assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" }); - assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } }); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, '"bitbucket":{"email"'); - assert.include(raw, "sourceControlHosts"); - assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); - }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + if (githubSource === "legacy") { + assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" }); + } + assert.deepEqual( + loaded.github, + githubSource === "canonical" + ? canonicalGithub + : { + hosts: { "github.com": { account: "work" } }, + tokens: { + provider: "placeholder", + reference: "placeholder", + "github.com": "ghp_saved", + }, + }, + ); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.isUndefined(JSON.parse(raw).bitbucket); + assert.isUndefined(JSON.parse(raw).github); + assert.notInclude(raw, "placeholder"); + assert.include(raw, "sourceControlHosts"); + if (githubSource === "legacy") { + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + } else { + assert.isFalse(reads.mock.calls.some(([name]) => name === retiredName)); + assert.isFalse(removes.mock.calls.some(([name]) => name === retiredName)); + } + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); it.effect( @@ -1929,7 +2463,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); failRename = true; - readDuringWrite = Effect.ignore(serverSettings.getSettings); + const concurrentRead = yield* Deferred.make(); + readDuringWrite = serverSettings.getSettings.pipe( + Effect.exit, + Effect.flatMap((exit) => Deferred.done(concurrentRead, exit)), + Effect.forkChild({ startImmediately: true }), + Effect.asVoid, + ); const failedUpdate = yield* serverSettings .updateProviderInstance({ operation: "upsert", @@ -1942,8 +2482,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { }) .pipe(Effect.result); assert.equal(failedUpdate._tag, "Failure"); + readDuringWrite = Effect.void; assert.equal( - (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0] + (yield* Deferred.await(concurrentRead)).providerInstances[instanceId]?.environment?.[0] ?.value, "sk-kept", ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ee4db5fa48acf..85cfb83fb54b1 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -16,6 +16,7 @@ import { ServerSettings, ServerSettingsError, type ServerSettingsPatch, + UsageLimitSourceId, globalIdleCompaction, isSecretReference, type SecretReference, @@ -55,12 +56,16 @@ import { } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { secretProviderDefinitions } from "./secrets/secretProviderDefinitions.ts"; -import { - readSecretFieldValues, - sourceControlHostSecretName, -} from "./sourceControl/sourceControlHostSecrets.ts"; +import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; -import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; +import { + mapSettingsSecrets, + mapSettingsSecretValues, + normalizeSettingsSecretFields, + normalizeSourceControlHostSecretFields, + SECRET_REDACTED, + type SettingsSecret, +} from "./settingsSecrets.ts"; import { DEFAULT_PROVIDER_INSTANCES } from "./provider/defaultProviderInstances.ts"; export { @@ -132,13 +137,35 @@ const normalizeServerSettings = ( ), ); -export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { +export function redactServerSettingsForClient( + settings: ServerSettings, + client: { readonly secretReferences: boolean }, +): ServerSettings { const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); - return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; + // Older clients decode secret fields as strings and fail on a reference, so they see a saved secret. + const shaped = client.secretReferences + ? redacted + : mapSettingsSecretValues(redacted, (value) => + isSecretReference(value) ? SECRET_REDACTED : value, + ); + return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) }; +} + +/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */ +function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings { + const references = new Map(); + mapSettingsSecretValues(current, (value, secretName) => { + if (isSecretReference(value)) references.set(secretName, value); + return value; + }); + if (references.size === 0) return next; + return mapSettingsSecretValues(next, (value, secretName) => + value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value, + ); } // A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. @@ -258,6 +285,50 @@ function ensureProviderInstanceMutationAllowed( return Effect.void; } +const prepareSettingsPatch = Effect.fn("ServerSettings.prepareSettingsPatch")(function* ( + patch: ServerSettingsPatch, + settingsPath: string, +) { + if (hasConflictingCredentialHosts(patch)) + return yield* new ServerSettingsError({ + settingsPath, + operation: "unsupported-secret-reference", + cause: "Multiple credential host keys identify the same host. Keep one entry per host.", + }); + const sourceControlHosts = patch.sourceControlHosts; + return sourceControlHosts === undefined + ? patch + : { + ...patch, + sourceControlHosts: yield* normalizeSourceControlHostSecretFields( + sourceControlHosts, + settingsPath, + ), + }; +}); + +const validateUsageLimitDestinations = Effect.fn("ServerSettings.validateUsageLimitDestinations")( + function* ( + current: ServerSettings, + patch: ServerSettingsPatch["usageLimitSources"], + settingsPath: string, + ) { + for (const [id, source] of Object.entries(patch ?? {})) { + if (source === null || !Object.hasOwn(current.usageLimitSources, id)) continue; + const previous = current.usageLimitSources[UsageLimitSourceId.make(id)]; + if (previous === undefined || previous.url === source.url.trim()) continue; + const key = source.managementKey; + if (typeof key === "string" && (key.trim() === "" || key.trim() === SECRET_REDACTED)) { + return yield* new ServerSettingsError({ + settingsPath, + operation: "unsupported-secret-reference", + cause: "Provide a management key or secret reference when changing a usage source URL.", + }); + } + } + }, +); + const applySettingsUpdate = Effect.fn("ServerSettings.applySettingsUpdate")(function* ( current: ServerSettings, patch: ServerSettingsPatch, @@ -334,20 +405,22 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( ), ); + const applyTestPatch = (patch: ServerSettingsPatch, mutation?: ProviderInstanceMutation) => + updateTestSettings((current) => + Effect.gen(function* () { + const prepared = yield* prepareSettingsPatch(patch, ""); + yield* validateUsageLimitDestinations(current, prepared.usageLimitSources, ""); + return yield* applySettingsUpdate(current, prepared, "", mutation); + }), + ); + return { start: Effect.void, ready: Effect.void, getSettings, getRoutingSettings: getSettings, - updateSettings: (patch) => - updateTestSettings((currentSettings) => - applySettingsUpdate(currentSettings, patch, ""), - ), - - updateProviderInstance: (mutation, patch = {}) => - updateTestSettings((currentSettings) => - applySettingsUpdate(currentSettings, patch, "", mutation), - ), + updateSettings: (patch) => applyTestPatch(patch), + updateProviderInstance: (mutation, patch = {}) => applyTestPatch(patch, mutation), withSettingsSnapshot: (use) => writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), streamChanges: Stream.empty, @@ -392,46 +465,8 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson const isSettingsObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)); const decodeSettingsDocumentExit = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); -interface ForwardSettingsReference { - readonly path: ReadonlyArray; - readonly reference: SecretReference; - readonly secretName: string; -} - -const valueAtPath = (value: unknown, path: ReadonlyArray): unknown => { - for (const key of path) { - if (!isSettingsObject(value) || !Object.hasOwn(value, key)) return undefined; - value = value[key]; - } - return value; -}; - -const setAtPath = (value: Record, path: ReadonlyArray, next: unknown) => { - for (const key of path.slice(0, -1)) { - const child = Object.hasOwn(value, key) ? value[key] : undefined; - if (isSettingsObject(child)) { - value = child; - } else { - const created: Record = {}; - Object.defineProperty(value, key, { - value: created, - enumerable: true, - configurable: true, - writable: true, - }); - value = created; - } - } - Object.defineProperty(value, path[path.length - 1]!, { - value: next, - enumerable: true, - configurable: true, - writable: true, - }); -}; - const hasConflictingCredentialHosts = (settings: unknown) => { - const hosts = valueAtPath(settings, ["sourceControlHosts"]); + const hosts = isSettingsObject(settings) ? settings.sourceControlHosts : undefined; if (!isSettingsObject(hosts)) return false; for (const fields of Object.values(hosts)) { if (!isSettingsObject(fields) || !isSettingsObject(fields.tokens)) continue; @@ -445,67 +480,38 @@ const hasConflictingCredentialHosts = (settings: unknown) => { return false; }; -// A rollback keeps newer credentials opaque and unavailable until a supporting server returns. -const readForwardSettingsReferences = (raw: string) => { - const references: ForwardSettingsReference[] = []; +const readSettingsCompatibility = (raw: string) => { const decoded = decodeSettingsDocumentExit(raw); if (Exit.isFailure(decoded) || !isSettingsObject(decoded.value)) - return { raw, references, usageModelFilter: undefined, conflictingHosts: false }; + return { raw, usageModelFilter: undefined, conflictingHosts: false }; const document = decoded.value; - if (hasConflictingCredentialHosts(document)) { - return { raw, references, usageModelFilter: undefined, conflictingHosts: true }; - } - const keepReference = (path: ReadonlyArray, secretName: string) => { - const reference = valueAtPath(document, path); - if (!isSecretReference(reference)) return; - references.push({ path, reference, secretName }); - setAtPath(document, path, ""); - }; + if (hasConflictingCredentialHosts(document)) + return { raw, usageModelFilter: undefined, conflictingHosts: true }; const hosts = document.sourceControlHosts; if (isSettingsObject(hosts)) { - for (const [kind, fields] of Object.entries(hosts)) { + for (const fields of Object.values(hosts)) { if (!isSettingsObject(fields) || !isSettingsObject(fields.tokens)) continue; - for (const [host, reference] of Object.entries(fields.tokens)) { - if (!isSecretReference(reference)) continue; - const normalizedHost = host.trim().toLowerCase(); - Reflect.deleteProperty(fields.tokens, host); - const path = ["sourceControlHosts", kind, "tokens", normalizedHost]; - setAtPath(document, path, reference); - keepReference( - path, - sourceControlHostSecretName({ kind, field: "tokens", serverHost: normalizedHost }), - ); - } - } - } - const sources = document.usageLimitSources; - if (isSettingsObject(sources)) { - for (const id of Object.keys(sources)) { - keepReference( - ["usageLimitSources", id, "managementKey"], - `usage-limit-source-${Buffer.from(id).toString("base64url")}`, - ); + Object.assign(fields, { + tokens: Object.fromEntries( + Object.entries(fields.tokens).map(([host, value]) => [host.trim().toLowerCase(), value]), + ), + }); } } return { - raw: references.length > 0 ? JSON.stringify(document) : raw, - references, - usageModelFilter: document.usageModelFilter, + raw: JSON.stringify(document), + usageModelFilter: Object.hasOwn(ServerSettings.fields, "usageModelFilter") + ? undefined + : document.usageModelFilter, conflictingHosts: false, }; }; -const restoreForwardSettingsReferences = ( - raw: string, - references: ReadonlyArray, - usageModelFilter: unknown, -) => { - if (references.length === 0 && usageModelFilter === undefined) return raw; +const restoreForwardUsageModelFilter = (raw: string, usageModelFilter: unknown) => { + if (usageModelFilter === undefined) return raw; const document: unknown = JSON.parse(raw); if (!isSettingsObject(document)) throw new Error("Expected settings object"); - for (const { path, reference } of references) setAtPath(document, path, reference); - if (usageModelFilter !== undefined) setAtPath(document, ["usageModelFilter"], usageModelFilter); - return JSON.stringify(document, null, 2); + return JSON.stringify({ ...document, usageModelFilter }, null, 2); }; const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ @@ -525,17 +531,14 @@ const legacyGitHubTokenSecretName = (host: string) => function migrateLegacySourceControlSettings( settings: ServerSettings, - legacy: { - readonly bitbucket?: Readonly> | undefined; - readonly github?: Readonly> | undefined; - }, + legacy: ServerSettings["sourceControlHosts"], ): { readonly settings: ServerSettings; readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>; } { const sourceControlHosts = { ...settings.sourceControlHosts }; const secretMoves: Array<{ readonly from: string; readonly to: string }> = []; - if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) { + if (legacy.bitbucket !== undefined) { sourceControlHosts.bitbucket = legacy.bitbucket; for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) { if (legacy.bitbucket[field] !== SECRET_REDACTED) continue; @@ -545,10 +548,11 @@ function migrateLegacySourceControlSettings( }); } } - if (legacy.github !== undefined && sourceControlHosts.github === undefined) { + if (legacy.github !== undefined) { sourceControlHosts.github = legacy.github; - for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) { - if (serverHost === null || value !== SECRET_REDACTED) continue; + const tokens = legacy.github.tokens; + for (const [serverHost, value] of isSettingsObject(tokens) ? Object.entries(tokens) : []) { + if (value !== SECRET_REDACTED) continue; secretMoves.push({ from: legacyGitHubTokenSecretName(serverHost), to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }), @@ -788,6 +792,7 @@ const make = Effect.gen(function* () { const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + // Buffered changes carry the credentials resolved before another version can replace them. const emitChange = (settings: ServerSettings) => PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); @@ -813,7 +818,6 @@ const make = Effect.gen(function* () { ), ); - const protectedSettingsSecrets = new WeakMap>(); const conflictingHostsError = () => new ServerSettingsError({ settingsPath, @@ -822,18 +826,14 @@ const make = Effect.gen(function* () { }); const writeSettingsAtomically = Effect.fnUntraced( - function* ( - settings: ServerSettings, - references: ReadonlyArray = [], - usageModelFilter?: unknown, - ) { + function* (settings: ServerSettings, usageModelFilter?: unknown) { const sparseSettingsJson = yield* encodeServerSettingsJson( stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, ); return yield* writeFileStringAtomically({ filePath: settingsPath, - contents: `${restoreForwardSettingsReferences(sparseSettingsJson, references, usageModelFilter)}\n`, + contents: `${restoreForwardUsageModelFilter(sparseSettingsJson, usageModelFilter)}\n`, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), @@ -879,25 +879,26 @@ const make = Effect.gen(function* () { }, ); - const loadSettingsFromDisk = Effect.gen(function* () { + const loadSettingsFromDisk = Effect.fn("ServerSettings.loadSettingsFromDisk")(function* ( + usageLimitSourcesPatch?: ServerSettingsPatch["usageLimitSources"], + ) { let settings = DEFAULT_SERVER_SETTINGS; let legacyProviders: Readonly> | undefined; - let legacySourceControl: Parameters[1] = {}; + let legacySourceControl: ServerSettings["sourceControlHosts"] = {}; let parseFailure = Option.none(); - let forwardReferences: ReadonlyArray = []; let forwardUsageModelFilter: unknown; if (yield* readConfigExists) { const raw = yield* readRawConfig; - const compatible = readForwardSettingsReferences(raw); + const compatible = readSettingsCompatibility(raw); if (compatible.conflictingHosts) return yield* conflictingHostsError(); const decoded = decodeServerSettingsJsonExit(compatible.raw); const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { legacyProviders = legacySettings.value.providers; legacySourceControl = { - bitbucket: legacySettings.value.bitbucket, - github: legacySettings.value.github, + ...(legacySettings.value.bitbucket && { bitbucket: legacySettings.value.bitbucket }), + ...(legacySettings.value.github && { github: legacySettings.value.github }), }; } if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { @@ -914,14 +915,7 @@ const make = Effect.gen(function* () { } } else { settings = decoded.value; - forwardReferences = compatible.references; forwardUsageModelFilter = compatible.usageModelFilter; - if (forwardReferences.length > 0) { - yield* Effect.logWarning( - "newer settings credentials are unavailable in this server version", - { count: forwardReferences.length }, - ); - } } } // Settings saved by a server that kept the mode in `compactIdleThreads` move @@ -990,70 +984,103 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - // Remove retired keys only after copying secrets and writing the new file; failures retry on next load. - const legacySecretsCopied = yield* Effect.forEach( - settingsFileTrusted - ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves - : [], - (move) => - secretStore.get(move.from).pipe( - Effect.flatMap((secret) => - Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value), - ), - Effect.as(true), - Effect.catch(() => - Effect.logWarning("failed to copy a source control secret to its new name", { - secretName: move.to, - }).pipe(Effect.as(false)), - ), - ), - ).pipe(Effect.map((copied) => copied.every(Boolean))); + const normalized = settingsFileTrusted + ? yield* normalizeSettingsSecretFields(folded, settingsPath) + : folded; + yield* validateUsageLimitDestinations(normalized, usageLimitSourcesPatch, settingsPath); const hasLegacySourceControl = - settingsFileTrusted && - legacySecretsCopied && - (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined); + settingsFileTrusted && Object.keys(legacySourceControl).length > 0; + const { bitbucket, github } = legacySourceControl; const sourceControlMigration = hasLegacySourceControl - ? migrateLegacySourceControlSettings(folded, legacySourceControl) - : { settings: folded, secretMoves: [] }; - const migrated = settingsFileTrusted - ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings) - : folded; - if ( - migrated !== loaded || - (settingsFileTrusted && legacyProviders !== undefined) || - hasLegacySourceControl - ) { - yield* writeSettingsAtomically(migrated, forwardReferences, forwardUsageModelFilter); - for (const move of sourceControlMigration.secretMoves) { - yield* secretStore.remove(move.from).pipe(Effect.ignore); - } - } - protectedSettingsSecrets.set( - migrated, - new Set(forwardReferences.map(({ secretName }) => secretName)), + ? migrateLegacySourceControlSettings( + normalized, + yield* normalizeSourceControlHostSecretFields( + { + ...(bitbucket !== undefined && normalized.sourceControlHosts.bitbucket === undefined + ? { bitbucket } + : {}), + ...(github !== undefined && normalized.sourceControlHosts.github === undefined + ? { github } + : {}), + }, + settingsPath, + ), + ) + : { settings: normalized, secretMoves: [] }; + // Filesystem mutations can outlive cancellation, so finish them before releasing the permit. + return yield* Effect.uninterruptible( + Effect.gen(function* () { + // Remove retired keys only after copying secrets and writing the new file; failures retry on next load. + const legacySecretsCopied = yield* Effect.forEach( + sourceControlMigration.secretMoves, + (move) => + secretStore.get(move.from).pipe( + Effect.flatMap((secret) => + Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value), + ), + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to copy a source control secret to its new name", { + secretName: move.to, + }).pipe(Effect.as(false)), + ), + ), + ).pipe(Effect.map((copied) => copied.every(Boolean))); + if (!legacySecretsCopied) { + return { + settings: normalized, + parseFailure, + forwardUsageModelFilter, + pendingLegacySecretMigration: true, + }; + } + const beforeMove = sourceControlMigration.settings; + const migrated = settingsFileTrusted + ? yield* moveInlineSettingsSecrets(beforeMove) + : beforeMove; + if ( + folded !== loaded || + migrated !== beforeMove || + (settingsFileTrusted && legacyProviders !== undefined) || + hasLegacySourceControl + ) { + yield* writeSettingsAtomically(migrated, forwardUsageModelFilter); + for (const move of sourceControlMigration.secretMoves) { + yield* secretStore.remove(move.from).pipe(Effect.ignore); + } + } + return { + settings: migrated, + parseFailure, + forwardUsageModelFilter, + pendingLegacySecretMigration: false, + }; + }), ); - return { settings: migrated, parseFailure, forwardReferences, forwardUsageModelFilter }; }); - const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk, { + const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk(), { capacity: 1, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), }); - const getSettingsFromCache = Cache.get(settingsCache, cacheKey).pipe( - Effect.map((loaded) => loaded.settings), - ); - const reloadSettings = Cache.invalidate(settingsCache, cacheKey).pipe( - Effect.andThen(Cache.get(settingsCache, cacheKey)), - ); + // Cache.get interrupts and joins its abandoned loader before the caller releases the permit. + const getSettingsFromCache = Cache.get(settingsCache, cacheKey); + const reloadSettings = Effect.fn("ServerSettings.reloadSettings")(function* ( + usageLimitSourcesPatch?: ServerSettingsPatch["usageLimitSources"], + ) { + yield* Cache.invalidate(settingsCache, cacheKey); + if (usageLimitSourcesPatch === undefined) return yield* getSettingsFromCache; + const loaded = yield* loadSettingsFromDisk(usageLimitSourcesPatch); + yield* Cache.set(settingsCache, cacheKey, loaded); + return loaded; + }); const materializeSettingsSecrets = Effect.fn("serverSettings.materializeSettingsSecrets")( function* (settings: ServerSettings): Effect.fn.Return { const storedSecrets = new Map(); const pending = new Map(); - const protectedSecrets = protectedSettingsSecrets.get(settings); const readableSettings = mapSettingsSecrets(settings, (secret) => { - if (protectedSecrets?.has(secret.secretName)) return { value: "", redacted: false }; if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret); return secret; }); @@ -1085,47 +1112,34 @@ const make = Effect.gen(function* () { }, ); - // Background sweeps read settings every few seconds; reading secret files each time is wasted. - // Every secret write rewrites the settings file, so a new cached object implies fresh secrets. - // A read that overlaps a secret write may see files a rollback then restores, so it is not kept. + // Background sweeps reuse the secrets resolved for the current cached settings. let materialized: { readonly source: ServerSettings; readonly value: ServerSettings } | null = null; - let secretWriteGeneration = 0; - const invalidateMaterialized = Effect.sync(() => { - secretWriteGeneration++; - materialized = null; - }); - const getMaterializedSettings = getSettingsFromCache.pipe( - Effect.flatMap((source) => { - if (materialized?.source === source) return Effect.succeed(materialized.value); - const generation = secretWriteGeneration; - return materializeSettingsSecrets(source).pipe( - Effect.map(resolveTextGenerationProvider), - Effect.tap((value) => - Effect.sync(() => { - if (generation === secretWriteGeneration) materialized = { source, value }; - }), - ), - ); - }), + const materializeCachedSettings = Effect.fn("ServerSettings.materializeCachedSettings")( + function* (source: ServerSettings) { + if (materialized?.source === source) return materialized.value; + const value = resolveTextGenerationProvider(yield* materializeSettingsSecrets(source)); + materialized = { source, value }; + return value; + }, + ); + const getMaterializedSettingsUnlocked = getSettingsFromCache.pipe( + Effect.flatMap(({ settings }) => materializeCachedSettings(settings)), + ); + const getRoutingSettingsUnlocked = getSettingsFromCache.pipe( + Effect.flatMap((loaded) => + Option.isSome(loaded.parseFailure) + ? Effect.fail( + new ServerSettingsError({ + settingsPath, + operation: "read-file", + cause: loaded.parseFailure.value, + }), + ) + : Effect.succeed(loaded.settings), + ), + Effect.map(resolveTextGenerationProvider), ); - - const materializeChanges = (changes: Stream.Stream) => - changes.pipe( - Stream.mapEffect((settings) => - materializeSettingsSecrets(settings).pipe( - Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize settings secrets", { - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }).pipe(Effect.as(settings)), - ), - ), - ), - Stream.map(resolveTextGenerationProvider), - ); type SecretChange = { readonly secretName: string; @@ -1145,7 +1159,7 @@ const make = Effect.gen(function* () { }); const changes: SecretChange[] = []; const nextSecretNames = new Set(); - const settings = mapSettingsSecrets(next, (secret) => { + const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => { const metadata = { secretName: secret.secretName, ...(secret.providerInstanceId !== undefined @@ -1160,6 +1174,9 @@ const make = Effect.gen(function* () { return { value: secret.value, redacted: false }; } const old = previous.get(secret.secretName); + if (secret.redacted && (!old?.sensitive || (!old.redacted && old.value.length === 0))) { + return { value: "", redacted: false }; + } const inlineValue = old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined; const value = secret.redacted ? inlineValue : secret.value; @@ -1176,7 +1193,12 @@ const make = Effect.gen(function* () { return { value: "", redacted: true }; }); for (const secret of previous.values()) { - if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue; + if ( + !secret.sensitive || + (!secret.redacted && secret.value.length === 0) || + nextSecretNames.has(secret.secretName) + ) + continue; if (changes.some((change) => change.secretName === secret.secretName)) continue; changes.push({ kind: "remove", @@ -1270,16 +1292,19 @@ const make = Effect.gen(function* () { }; const updateAndPersistSettings = ( - update: (current: ServerSettings) => Effect.Effect, + patch: ServerSettingsPatch, + mutation?: ProviderInstanceMutation, ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { + yield* ensureSecretProviderWritesAllowed(patch, settingsPath); + const prepared = yield* prepareSettingsPatch(patch, settingsPath); const { settings: current, parseFailure, - forwardReferences, forwardUsageModelFilter, - } = yield* reloadSettings; + pendingLegacySecretMigration, + } = yield* reloadSettings(prepared.usageLimitSources); if (Option.isSome(parseFailure)) { return yield* new ServerSettingsError({ settingsPath, @@ -1287,61 +1312,65 @@ const make = Effect.gen(function* () { cause: parseFailure.value, }); } - const candidate = yield* update(current); - if (hasConflictingCredentialHosts(candidate)) return yield* conflictingHostsError(); - const updated = mapSettingsSecrets(candidate, (secret) => secret); - if (forwardReferences.some(({ path }) => valueAtPath(updated, path) !== "")) { + if (pendingLegacySecretMigration) { return yield* new ServerSettingsError({ settingsPath, - operation: "unsupported-secret-reference", - cause: "Update this credential with a server that supports secret references", + operation: "write-file", + cause: "Retry after the legacy source control secrets can be copied.", }); } + const candidate = yield* applySettingsUpdate(current, prepared, settingsPath, mutation); + if (hasConflictingCredentialHosts(candidate)) return yield* conflictingHostsError(); + const updated = yield* normalizeSettingsSecretFields(candidate, settingsPath); const persisted = yield* persistSettingsSecrets(current, updated); - const protectedSecrets = new Set(forwardReferences.map(({ secretName }) => secretName)); const next = yield* normalizeServerSettings(persisted.settings); - protectedSettingsSecrets.set(next, protectedSecrets); - const materializedNext = yield* Effect.uninterruptibleMask(() => + return yield* Effect.uninterruptible( Effect.gen(function* () { - yield* invalidateMaterialized; - const rollbackSecretChanges = yield* applySettingsSecretChanges( - persisted.changes.filter(({ secretName }) => !protectedSecrets.has(secretName)), - ); + const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(materializedExit.cause); } const writeExit = yield* Effect.exit( - writeSettingsAtomically(next, forwardReferences, forwardUsageModelFilter), + writeSettingsAtomically(next, forwardUsageModelFilter), ); if (Exit.isFailure(writeExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(writeExit.cause); } - return materializedExit.value; - }).pipe(Effect.ensuring(invalidateMaterialized)), + yield* Cache.set(settingsCache, cacheKey, { + settings: next, + parseFailure: Option.none(), + forwardUsageModelFilter, + pendingLegacySecretMigration: false, + }); + const resolved = resolveTextGenerationProvider(materializedExit.value); + materialized = { source: next, value: resolved }; + yield* emitChange(resolved); + return resolved; + }), ); - yield* Cache.set(settingsCache, cacheKey, { - settings: next, - parseFailure: Option.none(), - forwardReferences, - forwardUsageModelFilter, - }); - const resolved = resolveTextGenerationProvider(materializedNext); - materialized = { source: next, value: resolved }; - yield* emitChange(next); - return resolved; }), ); const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => - writeSemaphore.withPermits(1)(getMaterializedSettings.pipe(Effect.flatMap(use))); + writeSemaphore.withPermits(1)(getMaterializedSettingsUnlocked.pipe(Effect.flatMap(use))); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { - const { settings } = yield* reloadSettings; - yield* emitChange(settings); + const { settings } = yield* reloadSettings(); + const resolved = yield* materializeCachedSettings(settings).pipe( + Effect.catch((error) => + Effect.logWarning("failed to materialize settings secrets", { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }).pipe(Effect.as(resolveTextGenerationProvider(settings))), + ), + ); + yield* emitChange(resolved); }), ); @@ -1426,7 +1455,7 @@ const make = Effect.gen(function* () { const startup = Effect.gen(function* () { yield* startWatcher; - yield* reloadSettings; + yield* writeSemaphore.withPermits(1)(reloadSettings()); }); const startupExit = yield* Effect.exit(startup); @@ -1441,44 +1470,16 @@ const make = Effect.gen(function* () { return { start, ready: Deferred.await(startedDeferred), - getRoutingSettings: Cache.get(settingsCache, cacheKey).pipe( - Effect.flatMap((loaded) => - Option.isSome(loaded.parseFailure) - ? Effect.fail( - new ServerSettingsError({ - settingsPath, - operation: "read-file", - cause: loaded.parseFailure.value, - }), - ) - : Effect.succeed(loaded.settings), - ), - Effect.map(resolveTextGenerationProvider), - ), - getSettings: getMaterializedSettings, - updateSettings: (patch) => - updateAndPersistSettings((current) => - ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( - Effect.andThen(applySettingsUpdate(current, patch, settingsPath)), - ), - ), - - updateProviderInstance: (mutation, patch = {}) => - updateAndPersistSettings((current) => - Effect.gen(function* () { - yield* ensureSecretProviderWritesAllowed(patch, settingsPath); - yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); - return yield* applySettingsUpdate(current, patch, settingsPath, mutation); - }), - ), + getRoutingSettings: writeSemaphore.withPermits(1)(getRoutingSettingsUnlocked), + getSettings: writeSemaphore.withPermits(1)(getMaterializedSettingsUnlocked), + updateSettings: (patch) => updateAndPersistSettings(patch), + updateProviderInstance: (mutation, patch = {}) => updateAndPersistSettings(patch, mutation), withSettingsSnapshot, get streamChanges() { - return materializeChanges(Stream.fromPubSub(changesPubSub)); + return Stream.fromPubSub(changesPubSub); }, get subscribeChanges() { - return PubSub.subscribe(changesPubSub).pipe( - Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))), - ); + return PubSub.subscribe(changesPubSub).pipe(Effect.map(Stream.fromSubscription)); }, } satisfies ServerSettingsService["Service"]; }); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d4daa659e58a..edda97bef76e7 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -3,11 +3,14 @@ import { ClaudeSettings, CodexSettings, EasDeviceHostConfig, + type ProviderInstanceConfig, type ServerSettings, + ServerSettingsError, + type SettingsSecretValue, SimlockDeviceHostConfig, UsageLimitSourceConfig, isSecretReference, - secretSettingsFields, + isSecretSetting, } from "@t3tools/contracts"; import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; @@ -15,27 +18,49 @@ import { grokClient } from "@t3tools/provider-grok/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as SchemaAST from "effect/SchemaAST"; import { - readSecretFieldValues, - SOURCE_CONTROL_HOST_SECRET_FIELDS, + sourceControlHostSettingsSchemas, sourceControlHostSecretName, - writeSecretFieldValues, } from "./sourceControl/sourceControlHostSecrets.ts"; export const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; +function describeSecretFields(schema: { readonly fields: Readonly> }) { + return Object.entries(schema.fields) + .filter(([, field]) => isSecretSetting(field)) + .map(([name, field]) => ({ + name, + keyed: SchemaAST.isObjects(field.ast) && field.ast.indexSignatures.length > 0, + // The registered settings schemas decode without services. + decode: Schema.decodeUnknownExit(field as Schema.Decoder), + })); +} + +type SecretField = ReturnType[number]; + +const sourceControlSecretFields = new Map( + [...sourceControlHostSettingsSchemas].map(([kind, schema]) => [ + kind, + describeSecretFields(schema), + ]), +); + const providerSecretFields = new Map([ - ["codex", secretSettingsFields(CodexSettings)], - ["claudeAgent", secretSettingsFields(ClaudeSettings)], - ["antigravity", secretSettingsFields(AntigravitySettings)], + ["codex", describeSecretFields(CodexSettings)], + ["claudeAgent", describeSecretFields(ClaudeSettings)], + ["antigravity", describeSecretFields(AntigravitySettings)], ...[cursorClient, grokClient, museClient, openCodeClient, piClient, acpRegistryClient].map( (definition) => - [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const, + [definition.driverKind, describeSecretFields(definition.settingsSchema)] as const, ), ]); -const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); -const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); -const simlockDeviceHostSecretFields = secretSettingsFields(SimlockDeviceHostConfig); +const usageLimitSecretFields = describeSecretFields(UsageLimitSourceConfig); +const easDeviceHostSecretFields = describeSecretFields(EasDeviceHostConfig); +const simlockDeviceHostSecretFields = describeSecretFields(SimlockDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); export interface SettingsSecret { @@ -49,36 +74,170 @@ export interface SettingsSecret { } type SecretValue = Pick; +type SecretSlotName = (field: string, serverHost: string | null) => string; +type SecretFieldsMapper = ( + fields: Readonly>, + secretFields: ReadonlyArray, + name: SecretSlotName, +) => Record; +type ProviderEnvironment = NonNullable; export function mapSettingsSecrets( settings: ServerSettings, map: (secret: SettingsSecret) => SecretValue, ): ServerSettings { - const mapFields = ( - fields: Readonly>, - secretFields: ReadonlyArray, - name: (field: string, serverHost: string | null) => string, - ) => { + const mapFields = mapSecretFieldValues((value, secretName) => { + if (typeof value !== "string") return value; + const result = map({ + secretName, + kind: "field", + value, + redacted: value === SECRET_REDACTED, + sensitive: true, + }); + return result.redacted ? SECRET_REDACTED : result.value; + }); + + return mapSecretFieldGroups(settings, mapFields, (instanceId, environment) => + environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; + const result = map({ + secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, + kind: "environment", + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, + sensitive: variable.sensitive, + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); + if (reference !== undefined) return { ...rest, value: reference }; + return { + ...rest, + value: result.value, + ...(result.redacted ? { valueRedacted: true } : {}), + }; + }), + ); +} + +/** + * Maps each value a secret field holds, literal or reference, with the secret name of its slot. + * Provider environment variables are left as they are. + */ +export function mapSettingsSecretValues( + settings: ServerSettings, + map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue, +): ServerSettings { + return mapSecretFieldGroups(settings, mapSecretFieldValues(map)); +} + +function mapSecretFieldValues( + map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue, +): SecretFieldsMapper { + const mapValue = (value: unknown, secretName: string) => + typeof value === "string" || isSecretReference(value) ? map(value, secretName) : value; + return (fields, secretFields, name) => { const next = { ...fields }; - for (const field of secretFields) { - if (!(field in next) || isSecretReference(next[field])) continue; - next[field] = writeSecretFieldValues( - next[field], - readSecretFieldValues(next[field]).map(([serverHost, value]) => { - const result = map({ - secretName: name(field, serverHost), - kind: "field", - value, - redacted: value === SECRET_REDACTED, - sensitive: true, - }); - return [serverHost, result.redacted ? SECRET_REDACTED : result.value] as const; - }), - ); + for (const { name: field, keyed } of secretFields) { + if (!Object.hasOwn(fields, field)) continue; + const value = fields[field]; + if (keyed && value !== null && typeof value === "object" && !Array.isArray(value)) { + next[field] = Object.fromEntries( + Object.entries(value).map(([host, entry]) => [ + host, + mapValue(entry, name(field, host.trim().toLowerCase())), + ]), + ); + } else if (!keyed) { + next[field] = mapValue(value, name(field, null)); + } } return next; }; +} + +class InvalidSettingsSecretField extends Schema.TaggedError()( + "InvalidSettingsSecretField", + { + field: Schema.String, + expected: Schema.Literals(["credential value", "credential map with unique keys"]), + }, +) { + override get message(): string { + return `Expected a ${this.expected} for ${this.field}.`; + } +} +const ownKeyCount = (value: unknown) => + value !== null && typeof value === "object" ? Object.keys(value).length : 0; + +function decodeSecretFields( + traverse: (mapFields: SecretFieldsMapper) => A, + settingsPath: string, +): Effect.Effect { + let error: ServerSettingsError | undefined; + const decoded = traverse((fields, secretFields) => { + const next = { ...fields }; + for (const { name, keyed, decode } of secretFields) { + if (error !== undefined || !Object.hasOwn(fields, name)) continue; + const result = decode(fields[name]); + if ( + Exit.isFailure(result) || + (keyed && ownKeyCount(fields[name]) !== ownKeyCount(result.value)) + ) { + error = new ServerSettingsError({ + settingsPath, + operation: "unsupported-secret-reference", + // Schema failures can contain the rejected credential. + cause: new InvalidSettingsSecretField({ + field: name, + expected: keyed ? "credential map with unique keys" : "credential value", + }), + }); + } else { + next[name] = result.value; + } + } + return next; + }); + return error === undefined ? Effect.succeed(decoded) : Effect.fail(error); +} + +export function normalizeSettingsSecretFields(settings: ServerSettings, settingsPath: string) { + return decodeSecretFields((mapFields) => mapSecretFieldGroups(settings, mapFields), settingsPath); +} + +export function normalizeSourceControlHostSecretFields( + hosts: ServerSettings["sourceControlHosts"], + settingsPath: string, +) { + return decodeSecretFields((mapFields) => mapSourceControlFields(hosts, mapFields), settingsPath); +} + +function mapSourceControlFields( + hosts: ServerSettings["sourceControlHosts"], + mapFields: SecretFieldsMapper, +): ServerSettings["sourceControlHosts"] { + return Object.fromEntries( + Object.entries(hosts).map(([kind, fields]) => [ + kind, + mapFields(fields, sourceControlSecretFields.get(kind) ?? [], (field, serverHost) => + sourceControlHostSecretName({ kind, field, serverHost }), + ), + ]), + ); +} + +function mapSecretFieldGroups( + settings: ServerSettings, + mapFields: SecretFieldsMapper, + mapEnvironment: (instanceId: string, environment: ProviderEnvironment) => ProviderEnvironment = ( + _instanceId, + environment, + ) => environment, +): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => { const config = instance.config; @@ -98,42 +257,14 @@ export function mapSettingsSecrets( } : {}), ...(instance.environment - ? { - environment: instance.environment.map((variable) => { - const { valueRedacted: _omit, ...rest } = variable; - // A secret reference is not itself secret; storage sees the value cleared. - const reference = typeof variable.value === "string" ? undefined : variable.value; - const result = map({ - secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, - kind: "environment", - value: typeof variable.value === "string" ? variable.value : "", - redacted: reference === undefined && variable.valueRedacted === true, - sensitive: variable.sensitive, - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - if (reference !== undefined) return { ...rest, value: reference }; - return { - ...rest, - value: result.value, - ...(result.redacted ? { valueRedacted: true } : {}), - }; - }), - } + ? { environment: mapEnvironment(instanceId, instance.environment) } : {}), }, ]; }), ); - const sourceControlHosts = Object.fromEntries( - Object.entries(settings.sourceControlHosts).map(([kind, fields]) => [ - kind, - mapFields(fields, SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? [], (field, serverHost) => - sourceControlHostSecretName({ kind, field, serverHost }), - ), - ]), - ) as ServerSettings["sourceControlHosts"]; + const sourceControlHosts = mapSourceControlFields(settings.sourceControlHosts, mapFields); const usageLimitSources = Object.fromEntries( Object.entries(settings.usageLimitSources).map(([id, source]) => [ diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index 00b648d0f0364..a7e9c3fe9bf77 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -24,7 +24,7 @@ export const layer = Layer.effect( getWithSecrets: (kind) => serverSettings.getSettings.pipe( Effect.flatMap((settings) => - resolveSourceControlHostSecrets(secrets, settings.sourceControlHosts, kind).pipe( + resolveSourceControlHostSecrets(secrets, settings, kind).pipe( Effect.map((sourceControlHosts) => sourceControlHosts === settings.sourceControlHosts ? settings diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index bfbe4a1c8ac94..d0b5499f6aba4 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { SecretProviderId } from "@t3tools/contracts"; +import { SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; @@ -14,7 +14,14 @@ const source = (reference: string) => ({ reference, }); -const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); +const secretsByReference = new Map([ + ["bitbucket#token", "bb_from_provider"], + ["github#token", "ghp_from_provider"], +]); + +const perHostHosts = { + github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } }, +}; const hosts = { bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, @@ -22,6 +29,8 @@ const hosts = { github: { tokens: { "github.com": "ghp_literal" } }, }; +const settings = { sourceControlHosts: hosts, secretProviders: {} }; + function countingResolver() { const reads: Array = []; const resolver = SecretResolver.SecretResolver.of({ @@ -42,7 +51,7 @@ it.effect( () => Effect.gen(function* () { const { reads, resolver } = countingResolver(); - const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + const resolved = yield* resolveSourceControlHostSecrets(resolver, settings, "bitbucket"); assert.deepEqual(resolved, { ...hosts, bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, @@ -54,7 +63,7 @@ it.effect( it.effect("treats a reference the provider cannot read as an unset credential", () => Effect.gen(function* () { const { reads, resolver } = countingResolver(); - const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "gitcafe"); + const resolved = yield* resolveSourceControlHostSecrets(resolver, settings, "gitcafe"); assert.deepEqual(resolved.gitcafe, { token: "" }); assert.deepEqual(reads, ["missing#token"]); }), @@ -63,17 +72,35 @@ it.effect("treats a reference the provider cannot read as an unset credential", it.effect("leaves the resolver unused for a kind that holds no reference", () => Effect.gen(function* () { const { reads, resolver } = countingResolver(); - const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "github"); + const resolved = yield* resolveSourceControlHostSecrets(resolver, settings, "github"); assert.strictEqual(resolved, hosts); assert.deepEqual(reads, []); }), ); +it.effect("replaces a per-host token reference and keeps the literal beside it", () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets( + resolver, + { ...settings, sourceControlHosts: perHostHosts }, + "github", + ); + assert.deepEqual(resolved, { + github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } }, + }); + assert.deepEqual(reads, ["github#token"]); + }), +); + it("collects the references held in credential fields and skips literal values", () => { assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ source("missing#token"), source("bitbucket#token"), ]); + assert.deepEqual(collectSourceControlHostSecretReferences(perHostHosts), [ + source("github#token"), + ]); assert.deepEqual( collectSourceControlHostSecretReferences({ bitbucket: { accessToken: "literal" }, @@ -103,13 +130,32 @@ it.effect("reads a collected reference again once it is invalidated", () => else for (const reference of references) cache.delete(reference.reference); }), }); - const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + const read = () => resolveSourceControlHostSecrets(resolver, settings, "bitbucket"); + const usageLimitSources = { + [UsageLimitSourceId.make("referenced")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: source("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("literal")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "plain-key", + enabled: true, + }, + } as const; + cache.set("cliproxy#key", "stale"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", }); current = "rotated"; - yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); + yield* invalidateSettingsSecrets( + resolver, + Effect.succeed({ sourceControlHosts: hosts, usageLimitSources }), + ); + assert.isFalse(cache.has("cliproxy#key")); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index cf86ade6b0eee..bf7fa90546e25 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,7 +1,6 @@ import { isSecretReference, secretReferencesInSettings, - secretSettingsFields, type SecretReference, type ServerSettings, } from "@t3tools/contracts"; @@ -12,16 +11,6 @@ import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; -export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< - string, - ReadonlyArray -> = new Map( - sourceControlDefinitions.definitions.flatMap((definition) => { - const fields = definition.settings ? secretSettingsFields(definition.settings) : []; - return fields.length > 0 ? [[definition.kind, fields] as const] : []; - }), -); - export interface SourceControlHostSecretSlot { readonly kind: string; readonly field: string; @@ -36,29 +25,9 @@ export function sourceControlHostSecretName(slot: SourceControlHostSecretSlot): : `source-control-${encode(slot.kind)}-${encode(slot.field)}-${encode(slot.serverHost.trim().toLowerCase())}`; } -export function readSecretFieldValues( - value: unknown, -): ReadonlyArray { - if (typeof value === "string") return [[null, value]]; - if (isSecretReference(value)) return []; - if (value === null || typeof value !== "object" || Array.isArray(value)) return []; - return Object.entries(value).flatMap(([serverHost, entry]) => - typeof entry === "string" ? [[serverHost.trim().toLowerCase(), entry] as const] : [], - ); -} - -export function writeSecretFieldValues( - original: unknown, - values: ReadonlyArray, -): unknown { - if (typeof original === "string") return values[0]?.[1] ?? ""; - if (isSecretReference(original)) return original; - return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); -} - type SourceControlHostsSettings = Readonly>>>; -const hostSettingsSchemas = new Map( +export const sourceControlHostSettingsSchemas = new Map( sourceControlDefinitions.definitions.flatMap((definition) => definition.settings ? [[definition.kind as string, definition.settings] as const] : [], ), @@ -68,7 +37,7 @@ const hostSettingsSchemas = new Map( export function collectSourceControlHostSecretReferences( hosts: SourceControlHostsSettings, ): ReadonlyArray { - return [...hostSettingsSchemas].flatMap(([kind, schema]) => + return [...sourceControlHostSettingsSchemas].flatMap(([kind, schema]) => secretReferencesInSettings(schema, hosts[kind]).map(({ reference }) => reference), ); } @@ -77,24 +46,40 @@ export function collectSourceControlHostSecretReferences( export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.resolveSecrets")( function* ( resolver: SecretResolver.SecretResolver["Service"], - hosts: SourceControlHostsSettings, + settings: Pick, kind: string, ) { - const schema = hostSettingsSchemas.get(kind); + const hosts = settings.sourceControlHosts; + const schema = sourceControlHostSettingsSchemas.get(kind); const fields = hosts[kind]; if (schema === undefined || fields === undefined) return hosts; - const resolved = yield* resolveSettingsSecrets(resolver, schema, fields); + const resolved = yield* resolveSettingsSecrets( + resolver, + schema, + fields, + settings.secretProviders, + ); return resolved === fields ? hosts : { ...hosts, [kind]: resolved }; }, ); +const collectUsageLimitSecretReferences = ( + sources: ServerSettings["usageLimitSources"], +): ReadonlyArray => + Object.values(sources).flatMap(({ managementKey }) => + isSecretReference(managementKey) ? [managementKey] : [], + ); + /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, + settings: Effect.Effect, E>, ) { const references = yield* settings.pipe( - Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.map((current) => [ + ...collectSourceControlHostSecretReferences(current.sourceControlHosts), + ...collectUsageLimitSecretReferences(current.usageLimitSources), + ]), Effect.orElseSucceed(() => undefined), ); yield* resolver.invalidate(references); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..b7e092539ca48 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,10 +1,14 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { defineSecretProviderDriver } from "@t3tools/secret-provider-core/server/driver"; +import { definition } from "@t3tools/secret-provider-onepassword/shared/definition"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -36,6 +40,10 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => ); }).pipe( Effect.provide([ + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, + }), ServerSettings.layerTest({ usageLimitSources: { [UsageLimitSourceId.make("local-hub")]: { @@ -83,3 +91,150 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); + +it.effect("reads a management key from its secret provider", () => { + const authorizations: Array = []; + const keySource = (reference: string) => ({ + provider: SecretProviderId.make("homelab"), + reference, + }); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + yield* sources.refresh; + const snapshots = yield* sources.current; + expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([ + { id: "resolved-hub", error: undefined }, + { + id: "unresolved-hub", + error: "Could not read the management key from its secret provider.", + }, + ]); + expect(authorizations).toEqual(["Bearer resolved-key"]); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("resolved-hub")]: { + kind: "cliproxy", + url: "https://resolved.test", + managementKey: keySource("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("unresolved-hub")]: { + kind: "cliproxy", + url: "https://unresolved.test", + managementKey: keySource("missing#key"), + enabled: true, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed( + references.map((reference) => + reference.reference === "cliproxy#key" ? "resolved-key" : undefined, + ), + ), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + authorizations.push(request.headers.authorization); + return HttpClientResponse.fromWeb(request, Response.json({ files: [] })); + }), + ), + ), + NodeCrypto.layer, + ]), + Effect.scoped, + ); +}); + +it.effect( + "keeps the hub destination paired with its secret provider account during refresh", + () => { + const providerId = SecretProviderId.make("homelab"); + const sourceId = UsageLimitSourceId.make("hub"); + const provider = (account: string) => ({ + kind: definition.kind, + label: account, + config: { account }, + }); + const source = (account: string) => ({ + kind: "cliproxy" as const, + url: `https://${account}.test`, + managementKey: { provider: providerId, reference: "op://vault/hub/key" }, + enabled: true, + }); + const driver = defineSecretProviderDriver({ + kind: definition.kind, + definition, + read: ({ account }, references) => Effect.succeed(references.map(() => `${account}-key`)), + }); + const requests: Array = []; + + return Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const testFiber = yield* Effect.fiberId; + let changeAfterCapture = false; + const sources = yield* UsageLimitSources.make.pipe( + Effect.provideService(ServerSettings.ServerSettingsService, { + ...settings, + getSettings: settings.getSettings.pipe( + Effect.tap(() => + Effect.gen(function* () { + if (!changeAfterCapture || (yield* Effect.fiberId) !== testFiber) return; + changeAfterCapture = false; + yield* settings.updateSettings({ + usageLimitSources: { [sourceId]: source("new") }, + secretProviders: { [providerId]: provider("new") }, + }); + }), + ), + ), + }), + Effect.provide( + SecretResolver.layer({ + drivers: [driver], + secretProviders: settings.getSettings.pipe( + Effect.map((snapshot) => snapshot.secretProviders), + Effect.orElseSucceed(() => ({})), + ), + }), + ), + ); + changeAfterCapture = true; + yield* sources.refresh; + yield* sources.refresh; + expect(new Set(requests.map(([host, authorization]) => `${host}: ${authorization}`))).toEqual( + new Set(["old.test: Bearer old-key", "new.test: Bearer new-key"]), + ); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { [sourceId]: source("old") }, + secretProviders: { [providerId]: provider("old") }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push([new URL(request.url).host, request.headers.authorization]); + return HttpClientResponse.fromWeb(request, Response.json({ files: [] })); + }), + ), + ), + NodeServices.layer, + ]), + Effect.scoped, + ); + }, +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7a068cde85f6c..0c7bda9ecca09 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -4,7 +4,8 @@ import { type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, type ServerSettings, - type UsageLimitSourceConfig, + UsageLimitSourceConfig, + settingsSecretLiteral, type UsageLimitSourceId, type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; @@ -21,6 +22,9 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; +import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; @@ -46,11 +50,30 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } +function missingManagementKey(config: UsageLimitSourceConfig): string { + return typeof config.managementKey === "string" + ? "No management key configured." + : "Could not read the management key from its secret provider."; +} + /** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; + const secrets = yield* SecretResolver.SecretResolver; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( + config: UsageLimitSourceConfig, + providersSnapshot: ServerSettings["secretProviders"], + ) { + const resolved = yield* resolveSettingsSecrets( + secrets, + UsageLimitSourceConfig, + config, + providersSnapshot, + ); + return settingsSecretLiteral(resolved.managementKey); + }); const stateRef = yield* Ref.make>([]); const changes = yield* Effect.acquireRelease( PubSub.unbounded>(), @@ -60,13 +83,17 @@ export const make = Effect.gen(function* () { const readSource = Effect.fn("UsageLimitSources.readSource")(function* ( id: UsageLimitSourceId, config: UsageLimitSourceConfig, + providersSnapshot: ServerSettings["secretProviders"], ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; - if (config.managementKey.length === 0) { - return { ...base, accounts: [], error: "No management key configured." }; + const managementKey = yield* readManagementKey(config, providersSnapshot); + if (managementKey.length === 0) { + return { ...base, accounts: [], error: missingManagementKey(config) }; } - const accounts = yield* api.readAccounts(config).pipe(Effect.result); + const accounts = yield* api + .readAccounts({ url: config.url, managementKey }) + .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); return { ...base, accounts: [], error: accounts.failure.detail }; @@ -89,12 +116,13 @@ export const make = Effect.gen(function* () { const settings = yield* settingsService.getSettings.pipe( Effect.orElseSucceed((): ServerSettings | null => null), ); - const entries = Object.entries(settings?.usageLimitSources ?? {}).filter( + if (settings === null) return yield* publish([]); + const entries = Object.entries(settings.usageLimitSources).filter( ([, config]) => config.enabled, ); const snapshots = yield* Effect.forEach( entries, - ([id, config]) => readSource(id as UsageLimitSourceId, config), + ([id, config]) => readSource(id as UsageLimitSourceId, config, settings.secretProviders), { concurrency: 4 }, ); yield* publish(snapshots); @@ -108,13 +136,21 @@ export const make = Effect.gen(function* () { Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), ); const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { + if (!config?.enabled) { return yield* new UsageLimitSourceError({ detail: "The usage limit source is missing or disabled.", }); } - const result = yield* api.consume(config, input.accountId, input.creditId); - const snapshot = yield* readSource(input.sourceId, config); + const managementKey = yield* readManagementKey(config, settings.secretProviders); + if (managementKey.length === 0) { + return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) }); + } + const result = yield* api.consume( + { url: config.url, managementKey }, + input.accountId, + input.creditId, + ); + const snapshot = yield* readSource(input.sourceId, config, settings.secretProviders); const previous = yield* Ref.get(stateRef); yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); return result; @@ -167,4 +203,6 @@ export const make = Effect.gen(function* () { } satisfies UsageLimitSources["Service"]; }); -export const layer = Layer.effect(UsageLimitSources, make); +export const layer = Layer.effect(UsageLimitSources, make).pipe( + Layer.provide(SecretResolverLayer.layer), +); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index aaee756cb97b1..63b4612f5ac2f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -67,12 +67,17 @@ 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", + sessionId = "session-1", +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, - sessionId: "session-1", + sessionId, message: { id: `msg_${id}`, model, @@ -2065,6 +2070,111 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("filters original model IDs before aliases, pricing, buckets and session counts", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + claudeLine(1, 3, "orbit-1", "mixed") + + claudeLine(2, 5, "orbit-2", "mixed") + + claudeLine(3, 7, "drift-9", "mixed") + + claudeLine(4, 11, "drift-9", "excluded-only"), + ), + ); + const rates = { + "orbit-1": { input_cost_per_token: 0.000001, output_cost_per_token: 0.00001 }, + "orbit-2": { input_cost_per_token: 0.000002, output_cost_per_token: 0.00002 }, + }; + const include = { + // The codex selector must not match the Claude drift-9 records. + include: [ + { provider: "claude", model: "orbit-1" }, + { model: "orbit-2" }, + { provider: "codex", model: "drift-9" }, + ], + }; + const exclude = { exclude: [{ model: "drift-9" }] }; + const HOURLY: UsageSummaryInput = { + ...WINDOW, + resolution: "hour", + sinceTime: "2026-08-01T09:00:00Z", + untilTime: "2026-08-01T11:00:00Z", + }; + const cells = (summary: UsageSummary) => + summary.buckets.map(({ model, totals, costUsd, records, sessions }) => ({ + model, + outputTokens: totals.outputTokens, + costUsd: Math.round(costUsd * 1e6), + records, + sessions, + })); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + // Same service, concurrent reads: the in-flight key must keep these scans apart. + const [unfiltered, noOp, keepNothing, included, reordered, excluded, hourly] = + yield* Effect.all( + [ + service.readSummary(WINDOW), + service.readSummary({ ...WINDOW, modelFilter: { exclude: [] } }), + service.readSummary({ ...WINDOW, modelFilter: { include: [] } }), + service.readSummary({ ...WINDOW, modelFilter: include }), + service.readSummary({ + ...WINDOW, + modelFilter: { include: [...include.include].reverse() }, + }), + service.readSummary({ ...WINDOW, modelFilter: exclude }), + service.readSummary({ ...HOURLY, modelFilter: include }), + ], + { concurrency: "unbounded" }, + ); + + // Unfiltered keeps today's behavior: the alias folds drift-9 into orbit-1. + assert.deepStrictEqual(cells(unfiltered), [ + { model: "orbit-1", outputTokens: 21, costUsd: 240, records: 3, sessions: 2 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]); + assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); + assert.strictEqual(unfiltered.modelFilter, undefined); + assert.strictEqual(noOp, unfiltered); + assert.deepStrictEqual(keepNothing.buckets, []); + assert.strictEqual(keepNothing.sources[0]?.distinctSessions, 0); + + const kept = [ + { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]; + assert.deepStrictEqual(cells(included), kept); + assert.deepStrictEqual(cells(excluded), kept); + assert.deepStrictEqual(cells(hourly), kept); + assert.strictEqual(hourly.buckets[0]?.hourStart, "2026-08-01T10:00:00.000Z"); + for (const summary of [included, excluded, hourly]) { + assert.strictEqual(summary.sources[0]?.distinctSessions, 1); + } + // Equal filters share one scan and echo one canonical form. + assert.strictEqual(reordered, included); + assert.deepStrictEqual(included.modelFilter, { + include: [ + { provider: "claude", model: "orbit-1" }, + { provider: "codex", model: "drift-9" }, + { model: "orbit-2" }, + ], + }); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-service-model-filter-test", + home, + settings: { ...settings, usageModelAliases: { "drift-9": "orbit-1" } }, + ratesDocument: rates, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index beb6d2e6071be..81114f244f670 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -39,7 +39,7 @@ import type { TranscriptUsageFormat, UsageRecord, } from "@t3tools/provider-core/server/usage"; -import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; +import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -752,6 +752,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), modelAliases: resolveModelAliases(settings.usageModelAliases), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); const sources: UsageSource[] = []; @@ -922,6 +923,7 @@ export const make = Effect.gen(function* () { sources, pricing: pricing(), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), } satisfies UsageSummary; }); @@ -937,9 +939,15 @@ export const make = Effect.gen(function* () { input.untilTime ?? null, settings, input.awaitRefresh === true, + input.modelFilter ?? null, ]); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const readSummary = Effect.fn("UsageService.readSummary")(function* ( + requested: UsageSummaryInput, + ) { + const { modelFilter: requestedFilter, ...unfiltered } = requested; + const modelFilter = canonicalModelFilter(requestedFilter); + const input = modelFilter === undefined ? unfiltered : { ...unfiltered, modelFilter }; const settings = yield* readSettings; const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d8002295..a4971957f2d3d 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -18,6 +18,12 @@ import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +/** A hub with its management key already read from wherever the setting keeps it. */ +export interface CliproxyHub { + readonly url: UsageLimitSourceConfig["url"]; + readonly managementKey: string; +} + const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, @@ -162,7 +168,7 @@ export const makeCliproxyApi = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const management = Effect.fn("CliproxyApi.management")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, path: string, body?: unknown, ) { @@ -170,13 +176,13 @@ export const makeCliproxyApi = Effect.gen(function* () { try: () => new URL( `v0/management/${path}`, - config.url.endsWith("/") ? config.url : `${config.url}/`, + hub.url.endsWith("/") ? hub.url : `${hub.url}/`, ).toString(), catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }), }); const request = ( body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url) - ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`)); + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${hub.managementKey}`)); const response = yield* client .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body))) .pipe( @@ -190,13 +196,13 @@ export const makeCliproxyApi = Effect.gen(function* () { return response; }); - const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { - const response = yield* management(config, "auth-files"); + const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (hub: CliproxyHub) { + const response = yield* management(hub, "auth-files"); return (yield* decodeAuthFiles(response)).files; }); const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, url: string, data?: unknown, @@ -218,7 +224,7 @@ export const makeCliproxyApi = Effect.gen(function* () { // The OAuth usage endpoint can return 429 without a Claude Code user agent. "User-Agent": "claude-code/2.1.273", }; - const raw = yield* management(config, "api-call", { + const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", url, @@ -235,10 +241,10 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const credits = Effect.fn("CliproxyApi.credits")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { - const body = yield* apiCall(config, account, CREDIT_URL); + const body = yield* apiCall(hub, account, CREDIT_URL); const response = yield* decodeCreditList(body); const now = DateTime.toEpochMillis(yield* DateTime.now); return response.credits @@ -252,7 +258,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -266,7 +272,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); const usage = yield* decodeClaudeUsage(body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -295,7 +301,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }).limits, }; } - const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); + const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); const usage = yield* decodeCodexUsage(body); const toWindow = (window: typeof CodexWindow.Type | null | undefined) => window @@ -307,7 +313,7 @@ export const makeCliproxyApi = Effect.gen(function* () { : { windowDurationMins: window.limit_window_seconds / 60 }), } : null; - const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined)); + const available = yield* credits(hub, account).pipe(Effect.orElseSucceed(() => undefined)); const next = available?.[0]; const planType = usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; @@ -352,9 +358,9 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, ): Effect.fn.Return, UsageLimitSourceError> { - const accounts = yield* authFiles(config).pipe( + const accounts = yield* authFiles(hub).pipe( Effect.mapError((error) => isUsageLimitSourceError(error) ? error @@ -366,24 +372,24 @@ export const makeCliproxyApi = Effect.gen(function* () { (account) => !account.disabled && (account.provider === "codex" || account.provider === "claude"), ), - (account) => readAccount(config, account), + (account) => readAccount(hub, account), { concurrency: 4 }, ); }); const consume = Effect.fn("CliproxyApi.consume")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, accountId: string, creditId: string, ): Effect.fn.Return { const operation = Effect.gen(function* () { - const account = (yield* authFiles(config)).find((account) => account.id === accountId); + const account = (yield* authFiles(hub)).find((account) => account.id === accountId); if (!account || account.disabled || account.provider !== "codex") { return yield* new UsageLimitSourceError({ detail: "The Codex hub account is missing or disabled.", }); } - const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { + const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, creditId, @@ -400,7 +406,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } as const )[response.code]; if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; - const cleared = yield* management(config, "reset-quota", { + const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, }).pipe(Effect.result); return { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 17903cd062eae..8f94c84a3c470 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -3,6 +3,8 @@ import type { UsageBucket, UsageCategoryCost, UsageDay, + UsageModelFilter, + UsageModelSelector, UsageResolution, UsageTokenTotals, } from "@t3tools/contracts"; @@ -67,6 +69,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -88,6 +91,38 @@ export function resolveModelAliases( return resolved; } +/** Matches original IDs, so an alias cannot pull excluded usage into a kept model. */ +function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRecord): boolean { + if (filter === undefined) return true; + const model = record.model.trim(); + const matches = (selector: UsageModelSelector) => + selector.model === model && + (selector.provider === undefined || selector.provider === record.provider); + return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false); +} + +const selectorKey = (selector: UsageModelSelector) => + JSON.stringify([selector.provider ?? null, selector.model]); + +const canonicalSelectors = (selectors: readonly UsageModelSelector[]) => + [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))] + // Code-unit order, so servers in different locales echo the same form. + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([, selector]) => selector); + +/** Equal filters get one form, so they share scans and clients can compare echoed filters. */ +export function canonicalModelFilter( + filter: UsageModelFilter | undefined, +): UsageModelFilter | undefined { + if (filter === undefined) return undefined; + const exclude = filter.exclude?.length ? canonicalSelectors(filter.exclude) : undefined; + if (filter.include === undefined && exclude === undefined) return undefined; + return { + ...(filter.include === undefined ? {} : { include: canonicalSelectors(filter.include) }), + ...(exclude === undefined ? {} : { exclude }), + }; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; readonly duplicatesDropped: number; @@ -129,6 +164,7 @@ export class UsageAggregator { } add(input: UsageRecord, sourcePath?: string): boolean { + if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 98b28b1ee038d..b3d5c307e43e1 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -673,6 +673,8 @@ it.effect( {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + + { secretReferences: true }, ).pipe( Layer.provide(Layer.mock(LocalThreadHandoff.LocalThreadHandoff)({})), Layer.provide(UpstreamUpdates.layer), @@ -712,8 +714,8 @@ it.effect( key: "mod+alt+z", command: "terminal.split", }); - const expectedSettings = [duringDiscovery, latestSettings].map( - ServerSettings.redactServerSettingsForClient, + const expectedSettings = [duringDiscovery, latestSettings].map((value) => + ServerSettings.redactServerSettingsForClient(value, { secretReferences: true }), ); const expectedKeybindings = [duringAck, latestKeybindings]; // The two sources can interleave, but each must retain its own publication order. @@ -845,6 +847,8 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + + { secretReferences: true }, ).pipe( Layer.provide(Layer.mock(LocalThreadHandoff.LocalThreadHandoff)({})), Layer.provide(UpstreamUpdates.layer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c475b69cf96c4..f786723e9611f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -60,6 +60,8 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_V2_WS_METHODS, ORCHESTRATION_PROTOCOL_QUERY_PARAM, + CLIENT_CAPABILITIES_QUERY_PARAM, + SETTINGS_SECRET_REFERENCES_CAPABILITY, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, @@ -680,6 +682,11 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { ); } +function readSettingsClient(url: URL) { + const capabilities = url.searchParams.get(CLIENT_CAPABILITIES_QUERY_PARAM)?.split(",") ?? []; + return { secretReferences: capabilities.includes(SETTINGS_SECRET_REFERENCES_CAPABILITY) }; +} + function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; readonly acceptDeferredWork?: boolean; @@ -1265,6 +1272,7 @@ export const layerWsRpc = ( clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], reportProfiles: ServerBrowser.ProfileReporter, + settingsClient: { readonly secretReferences: boolean }, ) => Layer.effectContext( Effect.gen(function* () { @@ -1783,6 +1791,7 @@ export const layerWsRpc = ( : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, + settingsClient, ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -1845,6 +1854,7 @@ export const layerWsRpc = ( threadFindProgressive: true, secretProviderSettings: true, secretReferences: true, + settingsSecretReferences: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -2358,13 +2368,13 @@ export const layerWsRpc = ( { concurrency: "unbounded", discard: true }, ); } + if (input.reloadSecrets === true && input.instanceId === undefined) { + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); + } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } - if (input.reloadSecrets === true && input.instanceId === undefined) { - yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); - } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, @@ -2559,7 +2569,11 @@ export const layerWsRpc = ( [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, [WS_METHODS.serverGetSettings]: (_input) => - serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + serverSettings.getSettings.pipe( + Effect.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), + ), [WS_METHODS.accountRoutingPreview]: (input) => accountRouting.preview(input), [WS_METHODS.peerLinksList]: (_input) => peerLinks.list.pipe(Effect.map((links) => ({ links }))), @@ -2582,7 +2596,7 @@ export const layerWsRpc = ( const settings = yield* providerInstanceMutation === undefined ? serverSettings.updateSettings(nextPatch) : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); - return ServerSettings.redactServerSettingsForClient(settings); + return ServerSettings.redactServerSettingsForClient(settings, settingsClient); }), [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, @@ -3231,7 +3245,9 @@ export const layerWsRpc = ( ) : Stream.empty; const settingsUpdates = settingsChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -3446,7 +3462,13 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc(session, clientAnalyticsProps, serverBrowser, reportProfiles).pipe( + layerWsRpc( + session, + clientAnalyticsProps, + serverBrowser, + reportProfiles, + readSettingsClient(requestUrl.value), + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 05b4fa6a8f9da..ecc9c7241e0d7 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,3 +1,7 @@ +import { + desktopSchemeForVersion, + isUnlockDigitalPreviewVersion, +} from "@t3tools/shared/forkIdentity"; import type { DesktopAppBranding } from "@t3tools/contracts"; import { formatAppDisplayName } from "./branding.logic"; @@ -25,4 +29,10 @@ export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_LINK_SCHEME = + typeof window !== "undefined" && window.location.protocol.startsWith("t3code") + ? window.location.protocol.slice(0, -1) + : isUnlockDigitalPreviewVersion(APP_VERSION) + ? desktopSchemeForVersion(APP_VERSION) + : undefined; export const DESKTOP_DOWNLOAD_URL = "https://t3.codes/download"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e15424cf090fa..c2cf8363b7fb1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,5 @@ +import { APP_LINK_SCHEME } from "~/branding"; +import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; @@ -234,7 +236,7 @@ import { primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; -import { threadEnvironment, useThreadFamily } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { @@ -483,14 +485,6 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; - // Subagent rows, an open child's own row and its ancestors come from the family when the shell - // leaves idle children out. Every source agrees on the root, so the key never flips. - useThreadFamily( - routeKind === "server" ? environmentId : null, - serverThreadShell?.lineage.rootThreadId ?? - serverProjection?.thread.lineage.rootThreadId ?? - null, - ); // Only the shell-less fallback needs these; with a shell, the shell carries them. const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); @@ -2336,6 +2330,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef === null || !isServerThread ? null : resolveThreadReferenceCopyTarget({ + appScheme: APP_LINK_SCHEME, threadRef: activeThreadRef, title: activeThread?.title ?? "", openPanelPullRequestUrl, @@ -3194,6 +3189,11 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); + // Above the early return, where React Compiler can cache it for the side chat pane. + const sideChatParentPanel = { + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }; if (!activeThread) { return embedded ? null : ; @@ -3213,10 +3213,7 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} - sideChatParentPanel={{ - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }} + sideChatParentPanel={sideChatParentPanel} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3554,6 +3551,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > + (null); + const { approval } = entry; + return ( +
+

Approval needed from {entry.title}

+
+        {approval.detail || approval.requestKind}
+      
+ {approval.appName ?

{approval.appName}

: null} + {approval.responseCapability !== "live" ? ( +

+ Provider process is gone. Interrupt or restart the child thread to respond. +

+ ) : null} + {approval.reason ?

{approval.reason}

: null} + {approval.fullAccessOverride ? ( +

+ {fullAccessOverrideExplanation(approval.fullAccessOverride)} +

+ ) : null} +
+ { + if (!canRespond || responding || entry.approval.responseCapability !== "live") return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> +
+ {error !== null ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function ChildThreadApprovals({ threadRef }: { threadRef: ScopedThreadRef | null }) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + if (approvals.length === 0) return null; + return ( +
+ {approvals.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index 68ab4cd569e0d..8e640edec56d6 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,13 +1,16 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId } from "@t3tools/contracts"; import { + appThreadLink, formatThreadLink, + formatThreadLinkMarkdown, guessThreadLinkRef, percentDecodedThreadLinkId, type ThreadLink, unknownThreadLinkLabel, } from "@t3tools/shared/threadLinks"; +import { APP_LINK_SCHEME } from "~/branding"; import { useThreadRefs, useThreadShell } from "../../state/entities"; import { UnresolvedChip } from "../contextChipParts"; import { ThreadContextChip } from "../ThreadContextChip"; @@ -19,7 +22,7 @@ export function MarkdownThreadLink(props: { readonly label: string | null; }) { const environmentId = props.link.environmentId ?? props.environmentId; - const decodedId = percentDecodedThreadLinkId(props.link.threadId); + const decodedId = props.link.exact ? null : percentDecodedThreadLinkId(props.link.threadId); const written = useThreadShell(scopeThreadRef(environmentId, props.link.threadId)); const decoded = useThreadShell( written === null && decodedId !== null ? scopeThreadRef(environmentId, decodedId) : null, @@ -36,6 +39,13 @@ export function MarkdownThreadLink(props: { const ref = { environmentId: thread.environmentId, threadId: thread.id }; const title = thread.title.trim() || props.label || "Untitled thread"; return ( - + ); } diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2a33ea7676249..3c89ad59c08bf 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -41,6 +41,10 @@ export const ModelListRow = memo(function ModelListRow(props: { subProvider: props.subProvider, }; const modelName = getDisplayModelName(model); + const providerLabel = + props.subProvider && props.subProvider !== props.providerDisplayName + ? `${props.providerDisplayName} · ${props.subProvider}` + : props.providerDisplayName; const row = ( - {/* One row shape in every tab: the logo names the provider, so Favorites needs no second line. */} -
- - - {modelName} - {/* The logo is decorative, so name the provider for screen readers. */} - , {props.providerDisplayName} -
- } - /> - - {modelName} - {props.providerDisplayName} - - - {(props.showProvider ?? props.showInstanceBadge) && ( -
- +
+ + + {modelName} + , {props.providerDisplayName} +
+ } /> - - {props.providerDisplayName} + + {modelName} + {props.providerDisplayName} + + + {props.showNewBadge ? ( + + New + ) : null} + {props.unavailable ? ( + + Unavailable + + ) : null} +
+ {(props.showProvider ?? props.showInstanceBadge) || props.subProvider ? ( +
+ {providerLabel}
- )} - {props.subProvider ? ( - - {props.subProvider} - - ) : null} - {props.showNewBadge ? ( - - New - - ) : null} - {props.unavailable ? ( - - Unavailable - ) : null} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8148887caee30..8a9807cd715a3 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -299,7 +299,7 @@ function ModelPickerList({ return (
0 ? "flex-1" : "h-0", )} > @@ -840,7 +840,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { "relative flex flex-row overflow-hidden", props.docked ? "min-w-0 w-full sm:flex-1 max-sm:h-56 max-sm:min-h-0!" - : "w-screen max-w-90 max-h-86.5", + : "w-[min(22.5rem,calc(100vw-1rem))] max-h-86.5", )} style={ isSearching @@ -913,7 +913,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { >
diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb45..b703e5d05da97 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -79,7 +79,7 @@ export function RightPanelSurfaceContent(props: { readonly files: RightPanelFileProps; readonly sideChatParentPanel: SideChatParentPanel; }) { - const { surface, threadRef, visible } = props; + const { surface, threadRef, visible, onCloseSurface } = props; if (surface === null) return null; switch (surface.kind) { case "preview": @@ -176,7 +176,7 @@ export function RightPanelSurfaceContent(props: { parentRef={threadRef} threadId={surface.threadId} parentPanel={props.sideChatParentPanel} - onClose={() => props.onCloseSurface(surface)} + onClose={() => onCloseSurface(surface)} /> ); case "device": diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index f1df96dd92133..9d31ec35761e9 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -10,6 +10,7 @@ import { AgentElapsed } from "./AgentElapsed"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; +import type { ProviderInstanceEntry } from "../../providerInstances"; import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; @@ -52,20 +53,20 @@ function WorkflowMemberRow({ providerInstanceId, provider, providers, + providerEntries, driver, onOpen, - isThreadAvailable, }: { member: RuntimeSubagent; providerInstanceId: ProviderInstanceId; provider: ServerProvider | undefined; providers: ReadonlyArray | undefined; + providerEntries: ReadonlyArray; driver: ProviderDriverKind | undefined; onOpen: (threadId: string) => void; - isThreadAvailable: (threadId: string) => boolean; }) { const threadId = member.childThreadId; - const canOpen = threadId !== null && isThreadAvailable(threadId); + const canOpen = threadId !== null; return (
  • @@ -80,7 +81,12 @@ function WorkflowMemberRow({ /> } > - + {member.title} {member.status} {member.startedAt ? ( @@ -115,9 +121,9 @@ export function ThreadLineageWorkflowRow({ providerInstanceId, provider, providers, + providerEntries, driver, onOpenThread, - isThreadAvailable, onStop, stopping, stopDisabled, @@ -127,9 +133,9 @@ export function ThreadLineageWorkflowRow({ readonly providerInstanceId: ProviderInstanceId; readonly provider: ServerProvider | undefined; readonly providers: ReadonlyArray | undefined; + readonly providerEntries: ReadonlyArray; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; - readonly isThreadAvailable: (threadId: string) => boolean; readonly onStop?: (() => void) | undefined; readonly stopping: boolean; readonly stopDisabled: boolean; @@ -225,9 +231,9 @@ export function ThreadLineageWorkflowRow({ providerInstanceId={providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} - isThreadAvailable={isThreadAvailable} /> ))} @@ -242,9 +248,9 @@ export function ThreadLineageWorkflowRow({ providerInstanceId={providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} - isThreadAvailable={isThreadAvailable} /> ))} {phases.length === 0 && group.unphasedMembers.length === 0 ? ( diff --git a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx index c591a241c75f3..9a1453af9856b 100644 --- a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import { BotIcon, type LucideIcon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; export function threadRelationshipStatusLabel(status: string | null): string { @@ -45,23 +46,33 @@ export function threadRelationshipStatusLabel(status: string | null): string { export function ThreadRelationshipIcon({ driver, provider, + providerEntries = [], status, fallbackIcon: FallbackIcon = BotIcon, }: { driver?: ProviderDriverKind | undefined; provider?: ServerProvider | undefined; + providerEntries?: ReadonlyArray; status: string | null; fallbackIcon?: LucideIcon; }) { const iconClassName = "size-4 shrink-0 text-muted-foreground"; + const entry = driver + ? providerEntries.find((candidate) => candidate.instanceId === provider?.instanceId) + : undefined; + const showInstanceBadge = entry !== undefined && shouldShowInstanceBadge(entry, providerEntries); return ( {driver ? ( @@ -70,7 +81,8 @@ export function ThreadRelationshipIcon({ )} ({ })); import { render } from "../../../test/render"; +import { makeThreadProjectionFixture } from "../../test-fixtures"; import { ToastProvider } from "../ui/toast"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; @@ -134,3 +135,51 @@ it.each([ await expect.element(screen.getByRole("button", { name: "More thread actions" })).toBeEnabled(); } }); + +it("opens an idle child from Lineage when its shell has not been loaded", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + const childId = ThreadId.make("omitted-child"); + const environmentId = EnvironmentId.make("local"); + state.projection = { + ...projection, + subagents: [ + { + id: NodeId.make("agent"), + threadId: projection.thread.id, + runId: null, + parentNodeId: NodeId.make("parent"), + origin: "app_owned", + createdBy: "agent", + driver: "codex", + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: childId, + nativeTaskRef: null, + prompt: "Inspect the change", + title: "Idle child", + model: null, + status: "completed", + result: null, + startedAt: null, + completedAt: projection.thread.updatedAt, + updatedAt: projection.thread.updatedAt, + }, + ], + }; + await using screen = await render( + , + ); + await screen.getByRole("button", { name: /Previous agents/ }).click(); + const child = screen.getByRole("button", { name: /Idle child/ }); + await expect.element(child).toBeEnabled(); + await child.click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe("/local/omitted-child"), + ); +}); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index b95ae4093a8a6..a3eaf6e787baa 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -66,6 +66,11 @@ import { useThreadRelationshipShells, } from "../../state/entities"; import { useEnvironment, useEnvironmentProviders } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, +} from "../../providerInstances"; import { threadEnvironment } from "../../state/threads"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -357,6 +362,7 @@ export function ThreadRelationshipsPanel(props: { const ref = scopeThreadRef(props.environmentId, props.threadId); const projection = useThreadProjection(ref)?.projection ?? null; const providersByEnvironment = useEnvironmentProviders(); + const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); const relationshipShells = useThreadRelationshipShells(ref); const allProjects = useProjects(); const archived = useArchivedThreadSnapshots([props.environmentId]); @@ -394,6 +400,14 @@ export function ThreadRelationshipsPanel(props: { const [stoppingId, setStoppingId] = useState(null); // Derive graphs after hooks to preserve their compiler cache. const providers = providersByEnvironment.get(props.environmentId); + const derivedProviderEntries = deriveProviderInstanceEntries(providers ?? []); + const providerEntries = settings + ? applyProviderInstanceSettings( + derivedProviderEntries, + settings, + currentThread?.projectId ?? null, + ) + : derivedProviderEntries; const projects = allProjects.filter((project) => project.environmentId === props.environmentId); const parentNativeStatus = parentProjection ? deriveProviderSubagentStatus(parentProjection)?.status @@ -500,6 +514,7 @@ export function ThreadRelationshipsPanel(props: { return null; } + // Idle children can be omitted from the shell; opening one loads its own detail. const openThread = (threadId: ThreadId) => { if (openSideChatTab(ref, graph.nodes.get(threadId)?.thread ?? null)) return; void navigate({ @@ -643,9 +658,7 @@ export function ThreadRelationshipsPanel(props: { ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""; const project = projects.find((project) => project.id === node?.thread?.projectId); - const relationshipHint = node?.missing - ? "This related thread is unavailable" - : `Open ${relationship.toLowerCase()} in this chat`; + const relationshipHint = `Open ${relationship.toLowerCase()} in this chat`; const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; const relationshipTooltip = agent ? ( @@ -713,7 +727,6 @@ export function ThreadRelationshipsPanel(props: { size="sm" variant="ghost" part={workflowGroup ? "primary" : "row"} - disabled={node?.missing === true} onClick={() => openThread(threadId)} /> } @@ -731,11 +744,9 @@ export function ThreadRelationshipsPanel(props: { providerInstanceId={agent.providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={providerDriver} onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)} - isThreadAvailable={(memberThreadId) => - graph.nodes.get(memberThreadId as ThreadId)?.missing === false - } onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} stopping={stoppingId === agent.id} stopDisabled={stoppingId !== null} @@ -756,7 +767,6 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" part="primary" aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`} - disabled={node?.missing === true} onClick={() => openThread(threadId)} /> } diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index e73f5d1d1e9a7..e4389b4fee97b 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; +import { ModelSwitchRow } from "./V2LifecycleRow"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; @@ -245,6 +246,20 @@ function UserVideoAttachment({ } export function UserMessageRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const item = row.projectedItem?.item; + const modelSwitch = item?.type === "user_message" ? item.modelSwitch : undefined; + return ( + <> + {modelSwitch ? ( + + ) : null} + + + ); +} + +function UserMessageContent({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); return ctx.workflowOpening?.messageId === row.message.id ? ( @@ -240,7 +251,8 @@ function SubagentAvatar({ {status ? ( @@ -264,9 +276,13 @@ export function EnvironmentSubagentAvatar({ remote?: OrchestrationV2RemoteTaskChild | undefined; status?: OrchestrationV2TurnItem["status"] | undefined; }) { - const providersAtom = serverEnvironment.providersValueAtom( - remote?.environmentId ?? environmentId, - ); + const providerEnvironmentId = remote?.environmentId ?? environmentId; + const providersAtom = serverEnvironment.providersValueAtom(providerEnvironmentId); + const settings = useAtomValue(serverEnvironment.settingsValueAtom(providerEnvironmentId)); + const entries = (providers: ReadonlyArray | null | undefined) => { + const derived = deriveProviderInstanceEntries(providers ?? []); + return settings ? brandProviderInstanceEntries(derived, settings) : derived; + }; const providerDriver = useAtomValue( providersAtom, (providers) => @@ -275,8 +291,18 @@ export function EnvironmentSubagentAvatar({ const displayName = useAtomValue( providersAtom, (providers) => - providers?.find((provider) => provider.instanceId === providerInstanceId)?.displayName, + entries(providers).find((entry) => entry.instanceId === providerInstanceId)?.displayName, + ); + const accentColor = useAtomValue( + providersAtom, + (providers) => + entries(providers).find((entry) => entry.instanceId === providerInstanceId)?.accentColor, ); + const showBadge = useAtomValue(providersAtom, (providers) => { + const all = entries(providers); + const entry = all.find((candidate) => candidate.instanceId === providerInstanceId); + return entry !== undefined && shouldShowInstanceBadge(entry, all); + }); const iconUrl = useAtomValue( providersAtom, (providers) => @@ -289,6 +315,8 @@ export function EnvironmentSubagentAvatar({ driver={providerDriver ?? (remote === undefined ? driver : undefined)} displayName={displayName} iconUrl={iconUrl} + accentColor={accentColor} + showBadge={showBadge} /> ); } @@ -568,6 +596,30 @@ function EnvironmentHandoffRow({ return ; } +/** A same-harness model or effort change. Shows only what changed. */ +export function ModelSwitchRow(props: { + readonly environmentId: EnvironmentId; + readonly modelSwitch: OrchestrationV2ModelSwitch; +}) { + const providers = + useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; + const change = resolveModelSwitchChange(props.modelSwitch, providers); + if (change === null) return null; + const describe = (selection: ModelSelection, effort: string | undefined) => { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = model ? getTriggerDisplayModelName(model, provider?.driver) : selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function HandoffRow(props: { readonly environmentId: EnvironmentId; readonly endpoints: HandoffEndpoints; diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts new file mode 100644 index 0000000000000..f89142f882d94 --- /dev/null +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -0,0 +1,59 @@ +import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; +import { type ComposerContextClipboardFragment, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { describe, expect, it } from "vite-plus/test"; + +import { importPastedComposerText } from "./composerInlineTokenPaste"; + +const thread = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; +const clipboard = (data: Record) => ({ + getData: (type: string) => data[type] ?? "", +}); +const chip = (title: string) => + `[${title}](t3-context://v1/thread/${threadContextRecord(thread, title).contextId})`; + +/** Imports every record it is offered, as the composer does for a thread its agent can read. */ +const importAll = + (imported: Array) => + (fragment: ComposerContextClipboardFragment) => { + imported.push(fragment); + return new Map(fragment.records.map((record) => [record.contextId, record.contextId])); + }; + +describe("importPastedComposerText", () => { + it("pastes a copied thread link back as the chip it was copied from", () => { + const target = threadChipCopyTarget(thread, "Fix the login flow"); + const imported: Array = []; + const text = importPastedComposerText( + clipboard({ + "text/plain": `See ${target.value}`, + [COMPOSER_CONTEXT_CLIPBOARD_MIME]: encodeComposerContextFragment(target.fragment!)!, + }), + importAll(imported), + ); + expect(text).toBe(`See ${chip("Fix the login flow")}`); + expect(imported.map((fragment) => fragment.records)).toEqual([target.fragment!.records]); + }); + + it("makes a chip of a v2 link pasted without T3's clipboard format", () => { + const text = importPastedComposerText( + clipboard({ "text/plain": "[Login](t3-thread://v2/env-1/thread-1)" }), + importAll([]), + ); + expect(text).toBe(chip("Login")); + }); + + it("keeps the link when the composer cannot take the thread", () => { + const pasted = "t3code-unlock://threads/env-1/thread-1"; + expect(importPastedComposerText(clipboard({ "text/plain": pasted }), () => new Map())).toBe( + pasted, + ); + }); +}); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 32722ad6a9571..0862f81ee8355 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -1,5 +1,9 @@ import { ComposerContextId } from "@t3tools/contracts"; -import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; +import type { + ComposerContextClipboardFragment, + ComposerContextRecord, + ScopedThreadRef, +} from "@t3tools/contracts"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, decodeComposerContextFragment, @@ -9,7 +13,14 @@ import { collectComposerContextReferences, formatComposerContextReference, replaceComposerContextReferences, + threadLinkContextReferences, } from "@t3tools/shared/composerContextReferences"; + +import { readThreadShell } from "~/state/entities"; + +const threadTitle = (ref: ScopedThreadRef) => readThreadShell(ref)?.title; + +/** Pasted thread links count as context too, so they become chips like a copied chip does. */ export function readPastedComposerContext( clipboardData: Pick, ): ComposerContextClipboardFragment | null { @@ -17,11 +28,20 @@ export function readPastedComposerContext( const decodedFragment = decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); - if (decodedFragment === null) return null; + const threads = threadLinkContextReferences( + pastedText, + decodedFragment?.records ?? [], + threadTitle, + ); + const source = + decodedFragment?.source ?? + (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null); + if (source === null) return null; const pastedIds = new Set( - collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), + collectComposerContextReferences(threads.text).map((occurrence) => occurrence.contextId), ); - for (const record of decodedFragment.records) { + const records = new Map(); + for (const record of [...(decodedFragment?.records ?? []), ...threads.records]) { if ( record.kind === "preview-annotation" && !("payload" in record) && @@ -30,10 +50,12 @@ export function readPastedComposerContext( ) { pastedIds.add(record.screenshotContextId); } + records.set(record.contextId, record); } return { - ...decodedFragment, - records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), + version: 1, + source, + records: [...records.values()].filter((record) => pastedIds.has(record.contextId)), }; } @@ -47,18 +69,23 @@ export function importPastedComposerText( const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; - const text = - rewrittenIds && rewrittenIds.size > 0 - ? replaceComposerContextReferences(pastedText, (occurrence) => { - const nextId = rewrittenIds.get(occurrence.contextId); - return nextId - ? formatComposerContextReference({ - ...occurrence, - contextId: ComposerContextId.make(nextId), - kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind, - }) - : occurrence.source; + if (!rewrittenIds || rewrittenIds.size === 0) return pastedText; + const nextId = (contextId: string) => { + const id = rewrittenIds.get(contextId); + return id ? ComposerContextId.make(id) : undefined; + }; + const text = replaceComposerContextReferences(pastedText, (occurrence) => { + const contextId = nextId(occurrence.contextId); + return contextId + ? formatComposerContextReference({ + ...occurrence, + contextId, + kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind, }) - : pastedText; - return text; + : occurrence.source; + }); + // A thread the import refused, on a machine this agent cannot read, stays the link it was. + return threadLinkContextReferences(text, fragment?.records ?? [], threadTitle, (record) => + nextId(record.contextId), + ).text; } diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index e3abd8b951067..2106b0e407e0f 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; +import type { EnvironmentId, SecretReference, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -14,7 +14,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; -import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; import { Label } from "../ui/label"; /** Send the management key once to server secret storage; settings retain only a redaction marker. */ @@ -34,14 +34,16 @@ export function AddUsageLimitSourceDialog({ const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); - const [managementKey, setManagementKey] = useState(""); + const secretSources = useSecretSources(environmentId, "settingsSecretReferences"); + const [keyDraft, setKeyDraft] = useState(""); + const managementKey = typeof keyDraft === "string" ? keyDraft.trim() : keyDraft; const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + const canSave = trimmedUrl.length > 0 && managementKey !== ""; const reset = () => { setLabel(""); setUrl(""); - setManagementKey(""); + setKeyDraft(""); }; const save = () => { @@ -53,7 +55,7 @@ export function AddUsageLimitSourceDialog({ kind: "cliproxy", ...(label.trim() ? { label: label.trim() } : {}), url: trimmedUrl, - managementKey: managementKey.trim(), + managementKey, enabled: true, }, }, @@ -98,10 +100,15 @@ export function AddUsageLimitSourceDialog({
  • - setManagementKey(event.target.value)} + aria-label="Management key" + value={keyDraft} + sources={secretSources} + onChange={(event) => setKeyDraft(event.target.value)} + onSecretChange={(secret) => setKeyDraft(secret ?? "")} + onProviderPick={() => setKeyDraft("")} + onReferenceReject={() => setKeyDraft("")} />
    diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index 7e42bba4bc6c8..ca8192dd894e3 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -70,7 +70,9 @@ function environment() { environmentId, label: "Laptop", connection: { phase: state.connected ? "connected" : "disconnected" }, - serverConfig: state.connected ? { environment: { platform: "linux" } } : null, + serverConfig: state.connected + ? { environment: { platform: "linux", capabilities: { oneTimeSchedules: true } } } + : null, }; } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index c5043f51d7c26..a7540862bbab1 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -78,7 +78,8 @@ import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { WEBHOOK_SIGNATURE_DEFAULTS, - webhookDraftErrors, + scheduleDraftErrors, + toDateTimeLocalValue, webhookDeliveryLabel, defaultScheduledTaskProjectId, matchesScheduledTaskScope, @@ -137,6 +138,8 @@ const EMPTY_DRAFT: DraftState = { scheduleMode: "fixed", intervalMinutes: "15", timeOfDay: "09:00", + runAt: "", + savedRunAt: "", weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", @@ -285,8 +288,14 @@ function splitModelKey(value: string): ModelSelection | null { }; } +const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; return Number.isInteger(minutes) @@ -1172,6 +1181,10 @@ function ScheduledTaskEditorForm({ const { scope, connectedEnvironments } = useSettingsScope(); const [environmentId, setEnvironmentId] = useState(initialEnvironmentId); const environment = useEnvironment(environmentId); + // Older servers reject one-time schedules, so offer Once only where it can save. + const offersOnce = + environment?.serverConfig?.environment.capabilities.oneTimeSchedules === true || + task?.schedule.type === "once"; const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; const tasksQuery = useEnvironmentQuery( @@ -1279,7 +1292,7 @@ function ScheduledTaskEditorForm({ } const schedule = scheduleFromDraft(draft); if (schedule === null) { - for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); + for (const error of scheduleDraftErrors(draft)) reportFailure(error.title, error.description); return; } if ( @@ -1299,6 +1312,13 @@ function ScheduledTaskEditorForm({ reportFailure("Invalid interval", "Enter an interval of at least one minute."); return; } + if (schedule.type === "once" && !offersOnce) { + reportFailure( + "Once is not available here", + "This environment runs an older T3 Code. Update it to schedule a one-time task, or pick another schedule.", + ); + return; + } if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) { reportFailure("Checkout path is required", "Enter the path of the checkout to run in."); return; @@ -1353,7 +1373,8 @@ function ScheduledTaskEditorForm({ {draft.editingId ? "Edit task" : "New task"} - Run a prompt automatically on an interval, at a fixed time, or when a webhook is called. + Run a prompt automatically on an interval, at a fixed time, once at a set date, or when a + webhook is called. @@ -1581,18 +1602,39 @@ function ScheduledTaskEditorForm({ value={[draft.scheduleMode]} onValueChange={(values) => { const mode = values[0] as ScheduleMode | undefined; - if (mode === "fixed" || mode === "interval" || mode === "webhook") - setDraft((current) => ({ - ...current, - scheduleMode: mode, - prompt: - mode === "webhook" && !current.prompt.trim() - ? DEFAULT_WEBHOOK_PROMPT - : current.prompt, - })); + if ( + mode === "fixed" || + mode === "once" || + mode === "interval" || + mode === "webhook" + ) + setDraft((current) => { + const suggested = + mode === "once" && !current.runAt + ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000) + : null; + return { + ...current, + scheduleMode: mode, + // Keep the instant too, so an hour that repeats when clocks go back stays exact. + ...(suggested === null + ? {} + : { + runAt: toDateTimeLocalValue(suggested), + savedRunAt: suggested.toISOString(), + }), + prompt: + mode === "webhook" && !current.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : current.prompt, + }; + }); }} > At a time + {offersOnce || draft.scheduleMode === "once" ? ( + Once + ) : null} Every interval On webhook @@ -1768,6 +1810,20 @@ function ScheduledTaskEditorForm({
    ) : null}
    + ) : draft.scheduleMode === "once" ? ( +
    + + + setDraft((current) => ({ ...current, runAt: event.target.value })) + } + /> +
    ) : draft.scheduleMode === "fixed" ? (
    diff --git a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index e1b67465c2168..18ff271e97974 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -23,6 +23,7 @@ async function commitReference(screen: Screen, value: string) { describe("SecretSourceInput", () => { it("moves a reference to another provider only once one is committed for it", async () => { const onSecretChange = vi.fn(); + const onProviderPick = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onProviderPick={onProviderPick} />, ); await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); await userEvent.click(screen.getByRole("option", { name: "Work" })); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onProviderPick).toHaveBeenCalledOnce(); await expect.element(reference(screen)).toHaveValue(""); await commitReference(screen, "op://Shared/OpenAI/credential"); @@ -57,6 +60,8 @@ describe("SecretSourceInput", () => { />, ); + await commitReference(screen, "invalid-reference"); + await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await userEvent.clear(reference(screen)); await userEvent.keyboard("{Enter}"); expect(onSecretChange).toHaveBeenCalledExactlyOnceWith(undefined); @@ -64,6 +69,7 @@ describe("SecretSourceInput", () => { it("rejects a reference 1Password cannot read", async () => { const onSecretChange = vi.fn(); + const onReferenceReject = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onReferenceReject={onReferenceReject} />, ); await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onReferenceReject).toHaveBeenCalledOnce(); + await screen.rerender( + {}} + onSecretChange={onSecretChange} + onReferenceReject={onReferenceReject} + />, + ); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); + await commitReference(screen, "op://Private/OpenAI/corrected"); + expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ + provider: personal, + reference: "op://Private/OpenAI/corrected", + }); }); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 2fed001b09e91..666f8f49d0247 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -32,11 +32,14 @@ const STORED_VALUE = "__stored__"; const decodeSecretReference = Schema.decodeUnknownOption(SecretReference); /** Secret providers a setting can point at; empty when the environment cannot read references. */ -export function useSecretSources(environmentId: EnvironmentId): ReadonlyArray { +export function useSecretSources( + environmentId: EnvironmentId, + capability: "secretReferences" | "settingsSecretReferences" = "secretReferences", +): ReadonlyArray { const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders); const supported = useAtomValue( serverEnvironment.configValueAtom(environmentId), - (config) => config?.secretReferences === true, + (config) => config?.[capability] === true, ); if (!isSecretsFeatureEnabled || !supported) return NO_SECRET_SOURCES; return (Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>) @@ -64,6 +67,10 @@ type SecretSourceInputProps = Omit, "value" | readonly value: string | SecretReference; readonly sources: ReadonlyArray; readonly onSecretChange: (secret: SecretReference | undefined) => void; + /** Called when a provider is picked, before a reference for it exists. */ + readonly onProviderPick?: () => void; + /** Called when an edited reference is rejected, so the committed one no longer matches the field. */ + readonly onReferenceReject?: () => void; /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ readonly children?: (parts: { readonly source: ReactNode; @@ -76,6 +83,8 @@ export function SecretSourceInput({ value, sources, onSecretChange, + onProviderPick, + onReferenceReject, children, ...rest }: SecretSourceInputProps) { @@ -106,6 +115,7 @@ export function SecretSourceInput({ return; } const next = value as SecretProviderId; + if (next !== secret?.provider) onProviderPick?.(); setPendingProvider(next === secret?.provider ? null : next); }} > @@ -159,6 +169,8 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); + if (onReferenceReject !== undefined) setPendingProvider(provider); + onReferenceReject?.(); return; } setRejectedReference(null); diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx index 5662954ef7cd6..54b0018a249c7 100644 --- a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -101,7 +101,9 @@ export function SourceControlAppSignInSettings({ readHostCredentialSettings(settings, definition.kind), ); const signedIn = (signIns[host] ?? "").length > 0; - const tokenOverrides = (tokens[host] ?? "").length > 0; + const token = tokens[host]; + // A token read from a secret provider overrides the sign-in like a saved one. + const tokenOverrides = typeof token === "string" ? token.length > 0 : token !== undefined; const canSignIn = useAtomValue( sourceControlEnvironment.startSignIn.permissionAtom(environmentId), ); @@ -169,7 +171,7 @@ export function SourceControlAppSignInSettings({ /> ) : tokenOverrides ? ( - "Signed in, but the token saved below is used until you remove it." + "Signed in, but the token saved below is used while it can be read." ) : ( "Signed in" ); diff --git a/apps/web/src/components/settings/SourceControlTokenSettings.tsx b/apps/web/src/components/settings/SourceControlTokenSettings.tsx index 912de7ad91701..42bd95d120495 100644 --- a/apps/web/src/components/settings/SourceControlTokenSettings.tsx +++ b/apps/web/src/components/settings/SourceControlTokenSettings.tsx @@ -1,5 +1,11 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { + isSecretReference, + type EnvironmentId, + type SecretReference, + type SettingsSecretValue, +} from "@t3tools/contracts"; import { ExternalLinkIcon } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; import { useState } from "react"; import { useEnvironmentSettings } from "../../hooks/useSettings"; @@ -12,8 +18,11 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; import { Input } from "../ui/input"; -import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; + +const hasToken = (token: SettingsSecretValue | undefined) => + typeof token === "string" ? token.length > 0 : token !== undefined; export function SourceControlTokenSettings({ environmentId, @@ -34,28 +43,44 @@ export function SourceControlTokenSettings({ }); const defaultHost = definition.publicHost ?? ""; const [host, setHost] = useState(defaultHost); - const [draft, setDraft] = useState(""); + const secretSources = useSecretSources(environmentId, "settingsSecretReferences"); + const supportsSecretReferences = useAtomValue( + serverEnvironment.configValueAtom(environmentId), + (config) => config?.settingsSecretReferences === true, + ); + const [pending, setPending] = useState<{ + readonly key: string; + readonly draft: string | SecretReference; + } | null>(null); const [saving, setSaving] = useState(false); const normalizedHost = host.trim().toLowerCase(); - const isSaved = (tokens[normalizedHost] ?? "").length > 0; + const savedToken = tokens[normalizedHost]; + const isSaved = hasToken(savedToken); const savedHosts = Object.entries(tokens) - .filter(([, value]) => value.length > 0) + .filter(([, value]) => hasToken(value)) .map(([saved]) => saved); + const sourceKey = JSON.stringify([normalizedHost, savedToken ?? ""]); + const draft = + pending?.key === sourceKey ? pending.draft : isSecretReference(savedToken) ? savedToken : ""; + const submission = typeof draft === "string" ? draft.trim() : draft; - const save = async (target: string, token: string) => { + const save = async (target: string, token: SettingsSecretValue) => { setSaving(true); const result = await updateSettings({ environmentId, - // Send the whole map; other tokens' redaction markers preserve their values. input: { patch: { - sourceControlHosts: { [definition.kind]: { tokens: { ...tokens, [target]: token } } }, + sourceControlHosts: { + [definition.kind]: { + tokens: { ...(supportsSecretReferences ? {} : tokens), [target]: token }, + }, + }, }, }, }); setSaving(false); if (result._tag === "Success") { - setDraft(""); + setPending(null); onSaved(); } }; @@ -65,7 +90,7 @@ export function SourceControlTokenSettings({ className="grid gap-3" onSubmit={(event) => { event.preventDefault(); - if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim()); + if (normalizedHost && submission !== "") void save(normalizedHost, submission); }} > {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */} @@ -106,12 +131,15 @@ export function SourceControlTokenSettings({
    - setDraft(event.target.value)} + sources={secretSources} + onChange={(event) => setPending({ key: sourceKey, draft: event.target.value })} + onSecretChange={(secret) => setPending({ key: sourceKey, draft: secret ?? "" })} />
    @@ -132,7 +160,11 @@ export function SourceControlTokenSettings({ Remove ) : null} - diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index a2540f8310cac..a8ac6ee160906 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -7,7 +7,7 @@ import { ScheduledTaskId, type ScheduledTask, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { SidebarProjectGroupMember, @@ -176,6 +176,42 @@ describe("editing scheduled task branch settings", () => { }); }); +describe("one-time scheduled tasks", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("round-trips the run time through the local date-time input", () => { + const runAt = "2026-11-03T17:00:42.000Z"; + const draft = taskToDraft({ ...legacyTask, schedule: { type: "once", runAt } }); + expect(draft.scheduleMode).toBe("once"); + expect(scheduleFromDraft(draft)).toEqual({ type: "once", runAt }); + const moved = scheduleFromDraft({ ...draft, runAt: "2026-11-04T09:30" }); + expect(moved).toEqual({ type: "once", runAt: new Date("2026-11-04T09:30").toISOString() }); + expect(scheduleFromDraft({ ...draft, runAt: "" })).toBeNull(); + }); + + it("rejects a time skipped by daylight saving", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const draft = taskToDraft({ + ...legacyTask, + schedule: { type: "once", runAt: "2027-03-14T18:00:00.000Z" }, + }); + expect(scheduleFromDraft({ ...draft, runAt: "2027-03-14T02:30" })).toBeNull(); + }); + + it("keeps the saved offset when an edit stays in an hour that repeats", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + // 01:30 PST, the second 01:30 on 2026-11-01. + const draft = taskToDraft({ + ...legacyTask, + schedule: { type: "once", runAt: "2026-11-01T09:30:00.000Z" }, + }); + expect(scheduleFromDraft({ ...draft, runAt: "2026-11-01T01:35" })).toEqual({ + type: "once", + runAt: "2026-11-01T09:35:00.000Z", + }); + }); +}); + describe("webhook scheduled tasks", () => { const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const; const webhookTask: ScheduledTask = { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 27ddc59f84c7d..365bd09e041ad 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -52,7 +52,7 @@ export function validateScheduledTasksSearch(raw: Record) { }; } -export type ScheduleMode = "fixed" | "interval" | "webhook"; +export type ScheduleMode = "fixed" | "once" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; export interface DraftState { @@ -63,6 +63,10 @@ export interface DraftState { readonly scheduleMode: ScheduleMode; readonly intervalMinutes: string; readonly timeOfDay: string; + /** Local date and time as a `datetime-local` input value, such as 2026-11-02T15:00. */ + readonly runAt: string; + /** The stored ISO run time, sent back unchanged when the field was not edited. */ + readonly savedRunAt: string; readonly weekdays: ReadonlySet; readonly projectId: string; readonly threadId: string; @@ -96,7 +100,24 @@ export const WEBHOOK_SIGNATURE_DEFAULTS = { signaturePrefix: "sha256=", } as const; -export function webhookDraftErrors(draft: DraftState) { +/** Formats a date as a `datetime-local` input value in the browser's time zone. */ +export function toDateTimeLocalValue(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())}`; +} + +export function scheduleDraftErrors(draft: DraftState) { + if (draft.scheduleMode === "once") { + const runAt = new Date(draft.runAt); + if (Number.isNaN(runAt.getTime())) + return [{ title: "Invalid run time", description: "Pick a date and time." }]; + // A time skipped by a daylight saving change parses to a different clock time. + if (toDateTimeLocalValue(runAt) !== draft.runAt) + return [ + { title: "Invalid run time", description: "That time does not exist in your time zone." }, + ]; + return []; + } if (draft.scheduleMode !== "webhook") return []; if (parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes) === undefined) return [ @@ -144,8 +165,26 @@ export function webhookDeliveryLabel(delivery: { } export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null { + if (scheduleDraftErrors(draft).length > 0) return null; + if (draft.scheduleMode === "once") { + const saved = draft.savedRunAt ? new Date(draft.savedRunAt) : null; + if (saved !== null && toDateTimeLocalValue(saved) === draft.runAt) + return { type: "once", runAt: draft.savedRunAt }; + // A `datetime-local` value has no offset. In an hour that repeats when clocks go + // back, keep the saved time's offset; otherwise Date reads it as local time. + const withSavedOffset = + saved === null + ? null + : new Date(Date.parse(`${draft.runAt}Z`) + saved.getTimezoneOffset() * 60_000); + return { + type: "once", + runAt: + withSavedOffset !== null && toDateTimeLocalValue(withSavedOffset) === draft.runAt + ? withSavedOffset.toISOString() + : new Date(draft.runAt).toISOString(), + }; + } if (draft.scheduleMode === "webhook") { - if (webhookDraftErrors(draft).length > 0) return null; const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); const replayKey = draft.replayKey.trim(); const coalesceKey = draft.coalesceKey.trim(); @@ -206,11 +245,12 @@ export function taskToDraft(task: ScheduledTask): DraftState { title: task.title, prompt: task.prompt, enabled: task.enabled, - scheduleMode: - schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed", + scheduleMode: schedule.type === "fixed_time" ? "fixed" : schedule.type, intervalMinutes: schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15", timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00", + runAt: schedule.type === "once" ? toDateTimeLocalValue(new Date(schedule.runAt)) : "", + savedRunAt: schedule.type === "once" ? schedule.runAt : "", weekdays, projectId: task.projectId, threadId: task.threadId ?? "", diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx new file mode 100644 index 0000000000000..c29932f5757e1 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -0,0 +1,68 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Profiler } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { SidebarDraftBlock } from "./SidebarDraftBlock"; + +it("keeps draft previews current through edits, promotion and removal", async () => { + const initial = useComposerDraftStore.getState(); + using _store = { + [Symbol.dispose]: () => useComposerDraftStore.setState(initial, true), + }; + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + const environmentId = EnvironmentId.make("draft-preview-environment"); + const projectRef = scopeProjectRef(environmentId, ProjectId.make("draft-preview-project")); + const draftId = DraftId.make("draft-preview"); + const threadId = ThreadId.make("draft-preview-thread"); + const onRender = vi.fn(); + await using screen = await render( + +
      + +
    +
    , + ); + const row = screen.getByTestId("sidebar-draft-row"); + await expect.element(row).not.toBeInTheDocument(); + + await act(() => { + initial.setProjectDraftThreadId(projectRef, draftId, { threadId }); + initial.setPrompt(draftId, "First draft preview"); + }); + await expect.element(row).toHaveAccessibleName("First draft preview, Unsent draft"); + + const rendersBeforeOtherComposer = onRender.mock.calls.length; + await act(() => + initial.setPrompt(scopeThreadRef(environmentId, ThreadId.make("other")), "Other composer"), + ); + expect(onRender).toHaveBeenCalledTimes(rendersBeforeOtherComposer); + await act(() => initial.setPrompt(draftId, "Updated draft preview")); + await expect.element(row).toHaveAccessibleName("Updated draft preview, Unsent draft"); + + await act(() => initial.markDraftThreadPromoting(draftId)); + await expect.element(row).not.toBeInTheDocument(); + await act(() => initial.clearDraftThread(draftId)); + + const nextDraftId = DraftId.make("next-draft-preview"); + await act(() => { + initial.setProjectDraftThreadId(projectRef, nextDraftId, { threadId: ThreadId.make("next") }); + initial.setPrompt(nextDraftId, "Next draft preview"); + }); + await expect.element(row).toHaveAccessibleName("Next draft preview, Unsent draft"); + await act(() => initial.clearDraftThread(nextDraftId)); + await expect.element(row).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index a9301f30cb0e5..10581ecb5a679 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -8,6 +8,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useShallow } from "zustand/react/shallow"; import { cn } from "~/lib/utils"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; @@ -147,7 +148,15 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const draftsByThreadKey = useComposerDraftStore( + useShallow((store) => + Object.fromEntries( + Object.keys(store.draftThreadsByThreadKey).map( + (key) => [key, store.draftsByThreadKey[key]] as const, + ), + ), + ), + ); const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 6a1a7f7312d86..241e2dae17653 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,3 +1,4 @@ +import { APP_LINK_SCHEME } from "~/branding"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { openGuestShareDialog } from "../components/chat/guestShareDialogState"; @@ -297,7 +298,7 @@ export function useThreadActionMenu(input?: { } return; case "copy-thread": - void copyThreadReference(threadChipCopyTarget(threadRef, thread.title)); + void copyThreadReference(threadChipCopyTarget(threadRef, thread.title, APP_LINK_SCHEME)); return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a07ff9ae9b17e..891f3312ae833 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, + brandProviderInstanceEntries, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, @@ -254,6 +255,32 @@ describe("deriveProviderEntriesByEnvironment", () => { }); }); +describe("brandProviderInstanceEntries", () => { + it("keeps an instance a project turns on as a sibling for the instance badge", () => { + const codex = ProviderDriverKind.make("codex"); + const settings = { + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { driver: codex, enabled: false }, + }, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { + providerInstances: { [ProviderInstanceId.make("codex_work")]: { enabled: true } }, + }, + }, + }; + const entries = brandProviderInstanceEntries( + deriveProviderInstanceEntries([ + provider({ provider: codex, instanceId: "codex" }), + provider({ provider: codex, instanceId: "codex_work" }), + ]), + settings, + ); + const personal = entries.find((entry) => entry.instanceId === "codex"); + + expect(personal && shouldShowInstanceBadge(personal, entries)).toBe(true); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 75ce4837675dd..cbeabb11ac2c5 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -93,10 +93,7 @@ export function deriveProviderEntriesByEnvironment( const byEnvironment = new Map>(); for (const [environmentId, providers, settings] of providersByEnvironment) { const derived = deriveProviderInstanceEntries(providers); - // The snapshot includes instances enabled by any project; machine settings alone do not. - const entries = settings - ? derived.map((entry) => applyProviderInstanceBranding(entry, settings)) - : derived; + const entries = settings ? brandProviderInstanceEntries(derived, settings) : derived; byEnvironment.set( environmentId, new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), @@ -105,6 +102,14 @@ export function deriveProviderEntriesByEnvironment( return byEnvironment; } +// The snapshot includes instances enabled by any project; machine settings alone do not. +export function brandProviderInstanceEntries( + entries: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + return entries.map((entry) => applyProviderInstanceBranding(entry, settings)); +} + /** projectId applies project overrides; explicit null uses machine settings. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 07fffa97cfbe0..9d7aae58033f1 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities"; @@ -14,11 +15,9 @@ import { import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -41,18 +40,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); - -/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ -export function useThreadFamily( - environmentId: EnvironmentId | null, - rootThreadId: ThreadId | null, -): void { - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the subagent tree into the thread family registry while the caller shows it - useEffect(() => { - if (environmentId === null || rootThreadId === null) return; - return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId); - }, [environmentId, rootThreadId]); -} +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, +}); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("web-environment-thread:empty"), diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9a68a5fa724f7..f47a65cd08268 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,7 +8,11 @@ import { type UsageProviderKind, type UsageSummaryInput, } from "@t3tools/contracts"; -import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { + needsCursorKeychainAccess, + refreshUsage, + usageSummaryInput, +} from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -55,7 +59,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) => }); continue; } - const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const result = get( + serverEnvironment.usageSummary({ + environmentId, + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 459f3b9791d7f..af9a3b4a9cc55 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -3,6 +3,7 @@ // chat and settings routes, the sidebar and the right panel, through the same stores and // atoms the app reads. See renderScenarios.renders.tsx for the counting rules. import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -12,8 +13,10 @@ import { commands, page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { AppRoot } from "~/AppRoot"; +import { useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { getRouter, type AppRouter } from "~/router"; +import { useRightPanelStore } from "~/rightPanelStore"; import { ENVIRONMENT_ID, assistantItem, @@ -287,3 +290,62 @@ it("app-switch-settings-section", async () => { await measure(openSettings(router, "/settings/appearance")), ]); }); + +it("side chat renders stay bounded during parent composer changes", async () => { + using _clock = frozenClock(); + await using _app = (await mountApp(threadPath(A))).screen; + await expect.element(lastAnswer(A)).toBeVisible(); + await act(() => useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, A), B)); + await expect.element(lastAnswer(B)).toBeVisible(); + expect(isThreadSubscribed(A)).toBe(true); + expect(isThreadSubscribed(B)).toBe(true); + + const mainRun = runningRun(A, "side-bound-main", HISTORY_TURNS + 1, "side-bound-user"); + await act(() => emitThreadEvent(A, { type: "run.created", payload: mainRun })); + startCounting(); + for (let delta = 0; delta < 10; delta++) { + await act(async () => { + useComposerDraftStore + .getState() + .setRuntimeMode( + scopeThreadRef(ENVIRONMENT_ID, A), + delta % 2 === 0 ? "full-access" : "approval-required", + ); + await emitThreadEvent(A, { + type: "turn-item.updated", + payload: assistantItem( + A, + mainRun.id, + "side-bound-answer", + HISTORY_TURNS * 2, + `main update ${delta}`, + true, + ), + }); + }); + await expect.element(page.getByText(`main update ${delta}`, { exact: true })).toBeVisible(); + } + const mainChanges = stopCounting(); + expect(mainChanges.components.ChatView ?? 0).toBeGreaterThanOrEqual(9); + expect(mainChanges.components.SideChatPane ?? 0).toBeLessThanOrEqual(2); + + const sideRun = runningRun(B, "side-bound-side", HISTORY_TURNS + 1, "side-bound-side-user"); + startCounting(); + await act(() => emitThreadEvent(B, { type: "run.created", payload: sideRun })); + await act(() => + emitThreadEvent(B, { + type: "turn-item.updated", + payload: assistantItem( + B, + sideRun.id, + "side-bound-side-answer", + HISTORY_TURNS * 2, + "side update", + true, + ), + }), + ); + await expect.element(page.getByText("side update", { exact: true })).toBeVisible(); + const sideChanges = stopCounting(); + expect(sideChanges.components.SideChatPane ?? 0).toBeGreaterThan(0); +}); diff --git a/docs/user/composer.md b/docs/user/composer.md index db41f6cc013fe..0a602958f286d 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -285,9 +285,9 @@ Another thread can be context too. Type `@` followed by part of its title to pic the same server, or from a machine it links to while the Linked environments beta is on; those show the machine's name. On web and desktop you can also drag a thread out of the sidebar and drop it on the composer; a multi-selection drops together. On any client, **Copy thread** puts a -thread on the clipboard that pastes into a composer as its chip. The chip shows the thread's -current title and opens it when selected. Your prompt only carries a reference: the agent reads the thread's -history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a +link on the clipboard that pastes into a composer as a chip when its agent can read the thread. +The chip shows the thread's current title and opens it when selected. The prompt carries a +reference, and the agent reads the thread's history on demand. Attaching a thread does not change it, and the agent cannot send messages to it unless you ask. A thread from a linked machine, picked, pasted or dropped, stays a reference that the agent reads diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 16b5811b019c3..984f689b091a4 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -17,8 +17,9 @@ and modes you choose in a draft keep their permissions. | **Auto** | Uses the provider's automatic review to approve routine actions and ask about others. | | **Full access** | Allows commands and edits without approval prompts. | -Approve or reject requests in the conversation to let the agent continue. Permission modes do -not prevent the agent from asking questions about the task. +Approve or reject requests in the conversation to let the agent continue. Agents can ask +questions in any permission mode. Child approvals appear in the parent conversation with the +child thread’s title. ## Provider differences diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 4db105440f0e1..d24f91be7bae1 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -113,11 +113,15 @@ branch name remains. ## Scheduled tasks on mobile -Open **Settings → Scheduled tasks** to create recurring tasks or manage existing +Open **Settings → Scheduled tasks** to create scheduled tasks or manage existing ones across your connected environments. Use the settings filter to narrow the list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that -environment's time zone, which may differ from your phone's. +environment's time zone, which may differ from your phone's. Choose **Once** +to run a task a single time at a date and time in your own time zone. If the +environment is offline then, an enabled task runs once when it is back. Pause or +delete the task to cancel its upcoming run. To re-enable a paused task after its +time has passed, first choose a new time. Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ad3046762124d..6b3aa70ec6076 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -302,9 +302,11 @@ palette. Use **Settings → Keybindings** to find or customize shortcuts for searching files and copying a thread reference. A copied reference uses the thread's pull request -link when available, otherwise the thread itself, which pastes into a composer as -a thread chip. To copy the thread even when it has a PR, choose **Copy → Thread** -from its context menu, or **Copy thread** from its long-press menu on mobile. +link when available. A copied thread link opens in a connected T3 client, using +the installed app's link scheme when available, and pastes into a composer as a chip when +its agent can read that thread. To copy the thread even when it has a PR, choose +**Copy → Thread** from its context menu, or **Copy thread** from its long-press +menu on mobile. **Thread ID** copies the bare ID for scripts and the `t3` CLI. See [keybindings](./keybindings.md) for custom configuration. diff --git a/docs/user/usage.md b/docs/user/usage.md index 4094757f16cb9..f0ce2bc8aca4c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -87,6 +87,30 @@ marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish without writing again to environments that already saved. Changes are not queued after you close the dialog. +## Show only some models + +To count only some models, or to leave some out, add `usageModelFilter` to the server's +`settings.json` in its `userdata` directory, such as `~/.t3/userdata/settings.json`: + +```json +{ + "usageModelFilter": { + "include": [{ "provider": "claude", "model": "orbit-1" }, { "model": "orbit-2" }], + "exclude": [{ "model": "drift-9" }] + } +} +``` + +`include` keeps only the listed models and `exclude` leaves the listed models out. `provider` is +one of `claude`, `codex`, `cursor`, `grok`, `opencode`, `antigravity`, `pi`, or `muse`; omit it +to match a model ID from any provider. Use the model ID that Usage shows without **Map to**, such +as a provider-prefixed ID for Pi. Filters apply before **Map to**, so a model you leave out is not +counted under its mapped name. Usage applies each environment's own filter. Remove the setting to +show every model again. + +If the setting is invalid, Usage ignores it and shows every model, and the next save of any other +setting removes it from `settings.json`. + ## Track subscription limits **Usage → Limits** shows a named row for every enabled provider connection on the selected diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 378dc9e45760a..5e7bc3806fabf 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -21,9 +21,10 @@ {"id": "stack-links-every-layer", "area": "pull-requests", "kind": "guard", "pattern": "stacked PRs: every layer ends up linked", "experience": "Must keep working: for a 2-PR stack, both layers end up linked, not only the top.", "rule": "For a stack, call it for every layer, not just the current branch or the top PR.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch feature/error-detail (on main) and branch feature/frozen-host (on top of feature/error-detail) are committed locally. Nothing is pushed yet.", "mocks": {"link_pull_request": "default"}, "world": {"branch": "feature/frozen-host", "branches": {"feature/error-detail": "e1d2c3b"}, "commits": [{"sha": "f7e6d5c", "subject": "feat(server): detect a frozen host and say so"}, {"sha": "e1d2c3b", "subject": "feat(server): include the failing step in connection errors"}], "nextPullRequests": "The next pull request created on acme/app is #124, and the one after it is #125."}, "shellMocks": [{"match": "gh pr create.*(--base|-B)[= ]['\"]?main\\b", "output": "https://github.com/acme/app/pull/124"}, {"match": "gh pr create.*(--base|-B)[= ]['\"]?feature/error-detail\\b", "output": "https://github.com/acme/app/pull/125"}]}, "prompt": "Open both as stacked PRs: feature/error-detail against main, and feature/frozen-host on top of it.", "expect": {"linked": ["~/pull/124", "~/pull/125"]}, "notes": "No real 'only the top layer linked' miss exists after v2 (checked all stacked creates since Oct 2). The real run used watch because of 'Full stop'. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 10} {"id": "takeover-links-pr", "area": "pull-requests", "kind": "guard", "pattern": "takeover of a pasted PR links it", "experience": "Must keep working: on 'take over this PR and get it in', link the pasted PR before working on it.", "rule": "register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "context": "This worktree has the PR's branch fix/config-snapshot checked out.", "world": {"branch": "fix/config-snapshot", "commits": [{"sha": "abc1234", "subject": "fix(server): stream config snapshots so slow ones keep the socket alive"}], "diff": "diff --git a/apps/server/src/config/snapshot.ts b/apps/server/src/config/snapshot.ts\n--- a/apps/server/src/config/snapshot.ts\n+++ b/apps/server/src/config/snapshot.ts\n@@ -1,6 +1,8 @@\n-import { buildSnapshot, streamSnapshot } from './build';\n+import { streamSnapshot } from './build';\n \n export async function sendSnapshot(socket: Socket, config: Config) {\n- const snapshot = await buildSnapshot(config);\n- socket.send(snapshot);\n+ // Send in chunks so a slow snapshot never blocks the heartbeat.\n+ for await (const chunk of streamSnapshot(config)) {\n+ socket.send(chunk);\n+ }\n }\n", "files": {"apps/server/src/config/snapshot.ts": "import { streamSnapshot } from './build';\n\nexport async function sendSnapshot(socket: Socket, config: Config) {\n // Send in chunks so a slow snapshot never blocks the heartbeat.\n for await (const chunk of streamSnapshot(config)) {\n socket.send(chunk);\n }\n}\n", "apps/server/src/config/build.ts": "export async function buildSnapshot(config: Config) {\n return JSON.stringify(config);\n}\n\nexport async function* streamSnapshot(config: Config) {\n for (const [key, value] of Object.entries(config)) yield JSON.stringify({ key, value });\n}\n"}, "prs": [{"number": 123, "title": "fix(server): slow config snapshots no longer drop connections", "headRefName": "fix/config-snapshot", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Check", "state": "pending"}, {"name": "Test Web", "state": "pending"}], "author": {"login": "contributor"}}]}}, "prompt": "https://github.com/acme/app/pull/123\n\nThis looks legit. Take over and get it in.", "expect": {"calls": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "A run that calls watch_pull_request first (which also links) would fail this check. If the runner treats watch as a link, relax it.", "maxSteps": 14} {"id": "handback-unwatches", "area": "pull-requests", "kind": "guard", "pattern": "unwatch before handing back", "experience": "Must keep working: when the babysit goal is met and the user merges, call unwatch_pull_request before the hand-back reply.", "rule": "When monitoring is done and you hand the work back to the user, call unwatch_pull_request first", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Make CI setup survive npm registry outages, then open a PR. You are done when CI on the PR is green. I will merge it myself."}, {"role": "assistant", "text": "Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it; I will report when CI is green.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/.github/workflows/ci.yml", "old_string": " - run: pnpm install", "new_string": " - run: pnpm install || pnpm install --registry https://registry.npmmirror.com"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/.github/workflows/ci.yml has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"ci: fall back to the registry mirror when npm is down\""}, "result": "[ci/npm-outage abc1234] ci: fall back to the registry mirror when npm is down\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin ci/npm-outage"}, "result": "To github.com:acme/app.git\n * [new branch] ci/npm-outage -> ci/npm-outage\nbranch 'ci/npm-outage' set up to track 'origin/ci/npm-outage'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"ci: fall back to the registry mirror when npm is down\" --fill"}, "result": "https://github.com/acme/app/pull/124"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "alreadyLinked": false}}, {"tool": "watch_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "watching": true, "wasWatching": false}}]}], "mocks": {"unwatch_pull_request": "default"}, "world": {"branch": "ci/npm-outage", "commits": [{"sha": "abc1234", "subject": "ci: fall back to the registry mirror when npm is down"}], "diff": "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n--- a/.github/workflows/ci.yml\n+++ b/.github/workflows/ci.yml\n@@ -8,1 +8,1 @@\n- - run: pnpm install\n+ - run: pnpm install || pnpm install --registry https://registry.npmmirror.com\n", "prs": [{"number": 124, "title": "ci: fall back to the registry mirror when npm is down", "headRefName": "ci/npm-outage", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 12 checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", "expect": {"calls": [{"tool": "unwatch_pull_request", "args": {"url": "~/pull/124"}}], "notCalls": ["watch_pull_request"], "shellMustNot": ["gh pr merge"]}, "notes": "Unwatch was followed 7 of 7 times after the fixes. No real 'forgot to unwatch' miss was found, so this is a guard, not a bad case.", "maxSteps": 7} -{"id": "schedule-one-off-tomorrow", "area": "pull-requests", "kind": "guard", "pattern": "schedule asked: schedule_task, not the job", "experience": "Must keep working: a schedule ask creates a schedule_task with an object schedule and reports the next run, without running the check now.", "rule": "schedule_task creates persistent recurring work in the app scheduler. Pass schedule as a structured object / After scheduling a timer, report the returned cadence and next run time", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Check app for memory leaks", "schedule": {"type": "fixed_time", "timeOfDay": "23:00", "weekdays": [0]}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-25T23:00:00-07:00"}}, "shellMocks": [{"match": "\\bdate\\b", "output": "Saturday 2026-10-24 10:00 PDT"}]}, "prompt": "Definitely want to schedule something to check the app for memory leaks at some point tomorrow, maybe like 11pm?", "expect": {"calls": [{"tool": "schedule_task"}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "textMust": ["(11(:00)? ?[Pp]\\.?[Mm]|23:00)"]}, "notes": "No one-shot schedule type exists, so a weekly fixed_time plus a later delete is the accepted pattern."} +{"id": "schedule-one-off-tomorrow", "area": "pull-requests", "kind": "guard", "pattern": "schedule asked: schedule_task, not the job", "experience": "Must keep working: a schedule ask creates a schedule_task with an object schedule and reports the next run, without running the check now.", "rule": "schedule_task creates persistent recurring work in the app scheduler. Pass schedule as a structured object / After scheduling a timer, report the returned cadence and next run time", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Check app for memory leaks", "schedule": {"type": "once", "runAt": "2026-10-25T23:00:00-07:00"}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-25T23:00:00-07:00"}}, "shellMocks": [{"match": "\\bdate\\b", "output": "Saturday 2026-10-24 10:00 PDT"}]}, "prompt": "Definitely want to schedule something to check the app for memory leaks at some point tomorrow, maybe like 11pm?", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "textMust": ["(11(:00)? ?[Pp]\\.?[Mm]|23:00)"]}, "notes": "A one-off time uses a once schedule. Before once existed, a weekly fixed_time plus a later delete was accepted."} {"id": "control-pr-lookup-question", "area": "pull-requests", "kind": "control", "pattern": "PRs named only as background in a question", "experience": "Asking about PRs must not link, watch, or list them.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"prs": [{"number": 101, "title": "fix(web): small UI fix", "state": "MERGED", "diff": "diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx\n--- a/apps/web/src/components/Sidebar.tsx\n+++ b/apps/web/src/components/Sidebar.tsx\n@@ -10,1 +10,1 @@\n- {title}\n+ {title}\n"}, {"number": 102, "title": "fix(ws): cap reconnect backoff at 30s", "state": "MERGED", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return Math.min(base * 2 ** attempt, 60_000);\n+ return Math.min(base * 2 ** attempt, 30_000);\n"}, {"number": 103, "title": "chore: update dependencies", "state": "MERGED", "diff": "diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml\n--- a/pnpm-lock.yaml\n+++ b/pnpm-lock.yaml\n@@ -120,1 +120,1 @@\n- vitest: 4.0.1\n+ vitest: 4.0.3\n"}]}}, "prompt": "Did any of #101, #102, or #103 touch the reconnect backoff logic?", "expect": {"notCalls": ["link_pull_request", "watch_pull_request", "list_thread_pull_requests", "unlink_pull_request"]}, "notes": ""} {"id": "control-link-means-url", "area": "pull-requests", "kind": "control", "pattern": "'link the PR' means show the URL", "experience": "'Link the PR that did this' about a merged culprit PR means give the URL in the reply, not link_pull_request.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Why isn't the new thread selector grouping projects anymore?"}, {"role": "assistant", "text": "#120, merged yesterday, removed the grouping step when it moved the selector to the new list component.", "toolCalls": [{"tool": "Bash", "input": {"command": "git log --oneline -S groupByProject -- apps/web/src/ThreadSelector.tsx"}, "result": "8e1f2a3 refactor(web): move thread selector to the shared list (#120)"}, {"tool": "Bash", "input": {"command": "git show 8e1f2a3 --stat"}, "result": "commit 8e1f2a3\n\n refactor(web): move thread selector to the shared list (#120)\n\n apps/web/src/ThreadSelector.tsx | 14 ++------------"}]}], "world": {"prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}}, "prompt": "Link the pr that did this", "expect": {"notCalls": ["link_pull_request", "watch_pull_request"], "textMust": ["github\\.com/acme/app/pull/120"]}, "notes": "Least certain control: 'link' is ambiguous. The user's next message was 'File revert pr', which shows he wanted to see it, not track it."} {"id": "control-nightly-time-question", "area": "pull-requests", "kind": "control", "pattern": "question about a CI schedule is not a schedule ask", "experience": "'When does the next nightly go out?' is answered from the workflow file, with no scheduler tools.", "rule": "none", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time (UTC-7).", "shellMocks": [{"match": "^date\\b", "output": "Sat Oct 24 10:00:00 PDT 2026"}, {"match": "gh run list", "output": "completed\tsuccess\tnightly\tmain\tschedule\t991\t21m\t2026-10-24T09:00:00Z"}], "world": {"files": {".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 9 * * *'\n workflow_dispatch: {}\n"}}}, "prompt": "When does the next nightly go out? Want to start testing this asap", "expect": {"notCalls": ["schedule_task", "list_scheduled_tasks", "run_scheduled_task_now"]}, "notes": ""} {"id": "control-fix-with-background-pr", "area": "pull-requests", "kind": "control", "pattern": "ordinary code fix that names a PR", "experience": "A plain code fix that mentions the PR which added the bug needs no PR tools.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"files": {"src/session.ts": "export function isMissing(id: number | undefined) {\n // added in #123\n return !id;\n}\n"}}}, "prompt": "The isMissing guard that #123 added in src/session.ts treats an id of 0 as missing. Fix it. Don't open a PR, I'll review the diff here.", "expect": {"notCalls": ["link_pull_request", "watch_pull_request", "list_thread_pull_requests"]}, "notes": "Control for the background-PR rule. Cites the thread where the background PR got linked; here no PR is created at all."} {"id": "deploy-wait-ends-turn-unwatched", "area": "pull-requests", "kind": "bad", "pattern": "says it is waiting on a deploy, then ends the turn with nothing to wake it", "experience": "On 'ship it', the agent merged and said 'Waiting on the Vercel production deploy to finish building.', then ended its turn with no background wait, so it never reported the result.", "rule": "Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup.", "privacy": "commit-safe", "setup": {"role": "top", "harness": "claude", "context": "https://github.com/acme/app/pull/123 is linked to this thread and is not watched.", "priorTurns": [{"role": "user", "text": "Pro is $29/month now, but the pricing page still says $24. Fix it."}, {"role": "assistant", "text": "Fixed in [#123](https://github.com/acme/app/pull/123): the Pro plan now costs 2900 cents ($29/month). All checks pass, including the Vercel preview build. Ship it?", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/pricing/plans.ts", "old_string": " pro: { name: \"Pro\", price: 2400, interval: \"month\" },", "new_string": " pro: { name: \"Pro\", price: 2900, interval: \"month\" },"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/pricing/plans.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): pricing page shows the current Pro price\" && git push -u origin fix/pro-price"}, "result": "[fix/pro-price abc1234] fix(web): pricing page shows the current Pro price\n 1 file changed, 1 insertion(+), 1 deletion(-)\nbranch 'fix/pro-price' set up to track 'origin/fix/pro-price'."}, {"tool": "Bash", "input": {"command": "gh pr create --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t38s\nTypecheck\tpass\t1m4s\nTest\tpass\t2m11s\nVercel\tpass\t6m2s"}]}], "mocks": {"t3_background_wait": "default", "watch_pull_request": "default", "link_pull_request": "default"}, "world": {"branch": "fix/pro-price", "commits": [{"sha": "abc1234", "subject": "fix(web): pricing page shows the current Pro price"}], "diff": "diff --git a/apps/web/src/pricing/plans.ts b/apps/web/src/pricing/plans.ts\n--- a/apps/web/src/pricing/plans.ts\n+++ b/apps/web/src/pricing/plans.ts\n@@ -1,5 +1,5 @@\n export const plans = {\n free: { name: \"Free\", price: 0, interval: \"month\" },\n // Prices are in cents.\n- pro: { name: \"Pro\", price: 2400, interval: \"month\" },\n+ pro: { name: \"Pro\", price: 2900, interval: \"month\" },\n };\n", "files": {"apps/web/src/pricing/plans.ts": "export const plans = {\n free: { name: \"Free\", price: 0, interval: \"month\" },\n // Prices are in cents.\n pro: { name: \"Pro\", price: 2900, interval: \"month\" },\n};\n"}, "prs": [{"number": 123, "title": "fix(web): pricing page shows the current Pro price", "headRefName": "fix/pro-price", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}, {"name": "Vercel", "state": "pass"}]}], "vercel": "The repo is linked to the Vercel project acme/app and the vercel CLI is logged in. Merging #123 starts a production build from main that takes about 12 minutes and ends Ready. Until then `vercel ls` and `vercel inspect` show it as Building. A foreground command that waits for it, such as `vercel inspect --wait`, hits the Bash tool's timeout first and prints only 'Command timed out after 2m 0.0s' (10m 0.0s with the longest timeout). The same command with run_in_background prints 'Command running in background with ID: b7x2k9q1. Output is being written to: /tmp/claude/tasks/b7x2k9q1.output'."}, "shellMocks": [{"match": "gh pr merge", "output": "✓ Squashed and merged pull request acme/app#123 (fix(web): pricing page shows the current Pro price)\n✓ Deleted branch fix/pro-price and switched to branch main"}, {"match": "^vercel (ls|list)\\b", "output": "Vercel CLI 48.2.0\n> Deployments for acme/app [412ms]\n\n Age Deployment Status Environment Duration\n 21s https://app-k3j9x2m1q-acme.vercel.app ● Building Production --\n 2h https://app-p8d7f6e5w-acme.vercel.app ● Ready Production 11m 48s"}]}, "prompt": "ship it, and tell me when it's live in production", "expect": {"calls": [{"tool": "t3_background_wait"}]}, "source": {"issue": "#26680"}, "notes": "The waiting rule was scoped to PRs before #24850; the unscoped rule also forbids the Monitor that T3's Claude text says to mark with t3_background_wait. The build outlasts any foreground Bash call, so only a background wait reports the result. Claude only: t3_background_wait is a Claude tool.", "maxSteps": 16} +{"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index 3f9dc8f992454..239aed398d77a 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -31,6 +31,7 @@ describe("orchestration protocol compatibility", () => { expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( String(ORCHESTRATION_PROTOCOL_VERSION), ); + expect(socketUrl.searchParams.get("clientCapabilities")).toBe("settingsSecretReferences"); expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 3ca2e226db954..fccc68bff751e 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -1,6 +1,8 @@ import { + CLIENT_CAPABILITIES_QUERY_PARAM, ORCHESTRATION_PROTOCOL_QUERY_PARAM, ORCHESTRATION_PROTOCOL_VERSION, + SETTINGS_SECRET_REFERENCES_CAPABILITY, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -37,5 +39,6 @@ function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { export function appendOrchestrationProtocol(socketUrl: string): string { const url = new URL(socketUrl); url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + url.searchParams.set(CLIENT_CAPABILITIES_QUERY_PARAM, SETTINGS_SECRET_REFERENCES_CAPABILITY); return url.toString(); } diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b70b2ef17615f..82a2aafa00140 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -202,7 +202,7 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2&clientCapabilities=settingsSecretReferences", httpAuthorization: null, target, }); diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts index 508a2f5694d02..c1e26e4693f2d 100644 --- a/packages/client-runtime/src/handoff.test.ts +++ b/packages/client-runtime/src/handoff.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { resolveHandoffEndpoints, + resolveModelSwitchChange, resolvePendingHandoff, type HandoffTimelineRun, } from "./handoff.ts"; @@ -173,3 +174,98 @@ describe("pending context handoff", () => { ).toBeNull(); }); }); + +describe("model switch change", () => { + const selection = (model: string, options?: Record) => ({ + instanceId: from, + model, + ...(options === undefined + ? {} + : { options: Object.entries(options).map(([id, value]) => ({ id, value })) }), + }); + const providers = [ + { + instanceId: from, + models: ["gpt", "mini"].map((slug) => ({ + slug, + capabilities: { + optionDescriptors: [ + { id: "serviceTier", type: "select", options: [{ id: "fast", label: "Fast" }] }, + { + id: "reasoningEffort", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + })), + }, + ] as unknown as ServerProvider[]; + + it("shows only what changed, reading an unset effort as the default", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) }, + providers, + ), + ).toEqual({ modelChanged: false, effort: { from: "Medium", to: "High" } }); + expect( + resolveModelSwitchChange({ from: selection("gpt"), to: selection("mini") }, providers), + ).toEqual({ modelChanged: true, effort: null }); + }); + + it("ignores changes the user cannot see", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "medium" }) }, + providers, + ), + ).toBeNull(); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { serviceTier: "fast" }) }, + providers, + ), + ).toBeNull(); + // A cataloged model without an effort control ignores stale effort options. + expect( + resolveModelSwitchChange( + { + from: selection("plain", { reasoningEffort: "low" }), + to: selection("plain", { reasoningEffort: "high" }), + }, + [{ instanceId: from, models: [{ slug: "plain" }] }] as unknown as ServerProvider[], + ), + ).toBeNull(); + }); + + it("falls back to stated efforts without a catalog", () => { + expect( + resolveModelSwitchChange( + { + from: selection("gpt", { reasoningEffort: "low" }), + to: selection("gpt", { reasoningEffort: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { + from: selection("pi", { thinking: "low" }), + to: selection("pi", { thinking: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) }, + [], + ), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index a26c018ad9cdf..95ca64608d503 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -1,12 +1,20 @@ import type { ModelSelection, + OrchestrationV2ModelSwitch, OrchestrationV2Run, OrchestrationV2TurnItem, ProviderInstanceId, ServerProvider, + ServerProviderModel, } from "@t3tools/contracts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; +import { REASONING_SELECT_DESCRIPTOR_IDS } from "./modelTraits.ts"; import type { ThreadStructure } from "./state/models.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; export type HandoffTimelineRun = Pick< OrchestrationV2Run, @@ -80,3 +88,53 @@ function latestRunModelBefore( } return latest?.modelSelection.model; } + +export interface ModelSwitchChange { + readonly modelChanged: boolean; + /** Effort labels, set only when the effort changed. */ + readonly effort: { readonly from: string; readonly to: string } | null; +} + +/** + * What a recorded model switch visibly changed, or null when it changed neither the model nor the + * effort. The composer omits an untouched effort, so an unset effort reads as the model's default. + */ +export function resolveModelSwitchChange( + { from, to }: OrchestrationV2ModelSwitch, + providers: ReadonlyArray, +): ModelSwitchChange | null { + const modelsOf = (selection: ModelSelection) => + providers.find((provider) => provider.instanceId === selection.instanceId)?.models; + const fromEffort = resolveEffort(from, modelsOf(from)); + const toEffort = resolveEffort(to, modelsOf(to)); + const modelChanged = from.model !== to.model; + const effort = + fromEffort && toEffort && fromEffort.value !== toEffort.value + ? { from: fromEffort.label, to: toEffort.label } + : null; + return modelChanged || effort ? { modelChanged, effort } : null; +} + +/** The composer's reasoning control for the model; stated values only when the catalog lacks it. */ +function resolveEffort( + selection: ModelSelection, + models: ReadonlyArray | undefined, +): { readonly value: string; readonly label: string } | undefined { + const model = models?.find((candidate) => candidate.slug === selection.model); + if (model === undefined) { + // Pi states its effort as `thinking`; the catalog would otherwise mark it a reasoning control. + const value = [...REASONING_EFFORT_OPTION_IDS, "thinking"] + .map((id) => getModelSelectionStringOptionValue(selection, id)) + .find((candidate) => candidate !== undefined); + return value === undefined ? undefined : { value, label: value }; + } + const descriptor = model.capabilities?.optionDescriptors?.find( + (option) => option.type === "select" && REASONING_SELECT_DESCRIPTOR_IDS.has(option.id), + ); + if (descriptor === undefined || descriptor.type !== "select") return undefined; + const value = + getModelSelectionStringOptionValue(selection, descriptor.id) ?? + getProviderOptionCurrentValue(descriptor, selection); + if (typeof value !== "string") return undefined; + return { value, label: descriptor.options.find((option) => option.id === value)?.label ?? value }; +} diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts index 27129f11128b3..4ac36acedf761 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts @@ -62,6 +62,11 @@ describe("shouldShowInstanceBadge", () => { expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); }); + it("ignores a non-hex accent color", () => { + const entry = { driverKind: codex, accentColor: "blue", enabled: true }; + expect(shouldShowInstanceBadge(entry, [entry])).toBe(false); + }); + it("shows the badge when two entries share a driver, even without an accent", () => { const first = { driverKind: codex, accentColor: undefined, enabled: true }; const second = { driverKind: codex, accentColor: undefined, enabled: true }; diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts index 62f65445e8217..ad69b7275342b 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts @@ -74,7 +74,7 @@ export function shouldShowInstanceBadge( readonly enabled: boolean; }>, ): boolean { - if (entry.accentColor) return true; + if (normalizeProviderAccentColor(entry.accentColor)) return true; let sharedProviderCount = 0; for (const candidate of entries) { if (candidate.driverKind !== entry.driverKind) continue; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 9cddc35d9af25..45bc5276435b3 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1112,6 +1112,13 @@ export function createServerEnvironmentAtoms( ]); }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); + // Settings snapshots rebuild the filter object; only a changed filter should touch the Usage tab. + const usageModelFilterAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(settingsValueAtom(environmentId))?.usageModelFilter).pipe( + Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)), + Atom.withLabel(`environment-data:server:usage-model-filter:${environmentId}`), + ), + ); const providersValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( Atom.withLabel(`environment-data:server:providers:${environmentId}`), @@ -1207,6 +1214,7 @@ export function createServerEnvironmentAtoms( }), updateStateAtom, settingsValueAtom, + usageModelFilterAtom, providersValueAtom, providerAuthState: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:provider:auth-state", diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 9070b7628af1d..90dc68c21dd82 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -59,6 +59,40 @@ describe("thread relationships", () => { ).toBeNull(); }); + it.each(["fork", "subagent"] as const)( + "keeps the parent edge of an opened %s omitted from the shell", + (relationshipToParent) => { + const parent = ThreadId.make("parent"); + const child = ThreadId.make("omitted-child"); + const projection = { + thread: { + id: child, + forkedFrom: + relationshipToParent === "fork" + ? { type: "run", threadId: parent, runId: "source-run" } + : null, + lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent }, + }, + subagents: [], + contextTransfers: [], + } as never; + const graph = deriveThreadRelationshipGraph({ threads: [], projection }); + expect(immediateThreadRelationships(graph, child)).toEqual([ + expect.objectContaining({ + threadId: parent, + edge: expect.objectContaining({ + sourceThreadId: parent, + targetThreadId: child, + kind: relationshipToParent, + }), + }), + ]); + expect(resolveMergeBackTargetThreadId(projection)).toBe( + relationshipToParent === "fork" ? parent : null, + ); + }, + ); + it("keeps an older activity run visible over a newer cancelled run", () => { const parent = ThreadId.make("thread-parent"); const child = ThreadId.make("thread-child"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index a0c63adccaaca..2aecc93470c2f 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -80,7 +80,12 @@ export function deriveThreadRelationshipGraph(input: { ); }; - for (const thread of threads) { + const selectedThread = input.projection?.thread; + const lineageThreads = + selectedThread !== undefined && !threadsById.has(selectedThread.id) + ? [...threads, { ...selectedThread, activityRunStatus: null, status: null }] + : threads; + for (const thread of lineageThreads) { const parentThreadId = thread.forkedFrom?.type === "run" ? thread.forkedFrom.threadId diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index d5d2c4ec16d8d..95ee3eceffdd9 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -1,5 +1,9 @@ +import { presentThreadShell } from "./models.ts"; +import { threadKey } from "./entities.ts"; import { EnvironmentId, + ThreadId, + type ScopedThreadRef, NodeId, ProviderSessionId, RuntimeRequestId, @@ -7,11 +11,13 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; import * as Cause from "effect/Cause"; -import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; +import { v2Now, v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { createQuestionHistoryProjector, + createChildThreadApprovalsAtom, + type PendingThreadRequests, derivePendingThreadRequests, declinePendingApprovals, sendApprovalFeedback, @@ -353,3 +359,65 @@ it("restores old text answers without mutating history or replacing unchanged ro expect(project(answered)).toBe(result); expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]); }); + +it("loads only child approvals and retains their destination across environments", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + const parent = { environmentId: local, threadId: ThreadId.make("parent") }; + const childId = ThreadId.make("child"); + const children = Atom.make( + [local, remote].map((environmentId) => ({ + ...presentThreadShell(environmentId, v2ThreadShell), + id: childId, + title: environmentId, + hasPendingApprovals: true, + lastVisitedAt: environmentId === local ? null : "2026-10-19T00:00:00Z", + })), + ); + const details = Atom.family((_key: string) => + Atom.make({ + approvals: [ + { + requestId: RuntimeRequestId.make("same-request"), + requestKind: "command", + createdAt: "2026-10-19T00:00:00Z", + responseCapability: "live", + }, + ], + userInputs: [], + }), + ); + const readDetails = vi.fn((ref: ScopedThreadRef) => details(threadKey(ref))); + const approvalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: () => children, + pendingRequestsAtom: readDetails, + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(approvalsAtom(parent)); + const pending = registry.get(approvalsAtom(parent)); + expect(pending.map((entry) => entry.thread)).toEqual( + [local, remote].map((environmentId) => ({ environmentId, threadId: childId })), + ); + expect(pending.map((entry) => entry.title)).toEqual(["local", "remote"]); + registry.set( + children, + registry.get(children).map((child) => ({ ...child, lastVisitedAt: "2026-10-19T00:00:00Z" })), + ); + expect(registry.get(approvalsAtom(parent))).toBe(pending); + registry.set(details(threadKey({ environmentId: remote, threadId: childId })), { + approvals: [], + userInputs: [], + }); + expect(registry.get(approvalsAtom(parent))).toEqual([pending[0]]); + readDetails.mockClear(); + registry.set( + children, + registry + .get(children) + .map((child) => ({ ...child, hasPendingApprovals: false, hasPendingUserInput: true })), + ); + expect(registry.get(approvalsAtom(parent))).toEqual([]); + expect(readDetails).not.toHaveBeenCalled(); + unmount(); + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index e089eb36d08c7..a010018c5051d 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, + ScopedThreadRef, ThreadId, OrchestrationV2ThreadProjection, OrchestrationV2RuntimeRequest, @@ -10,6 +11,9 @@ import type { ProviderRequestKind, RuntimeRequestId, } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { parseThreadKey, threadKey } from "./entities.ts"; +import type { EnvironmentThreadShell } from "./models.ts"; import * as DateTime from "effect/DateTime"; import type { AtomCommandResult } from "./runtime.ts"; import type { ThreadStructure, ThreadStructureVisibleTurnItem } from "./models.ts"; @@ -339,3 +343,48 @@ export function createQuestionHistoryProjector< return previousResult; }; } + +export interface ChildThreadApproval { + readonly thread: ScopedThreadRef; + readonly title: string; + readonly approval: ThreadPendingApproval; +} + +export function createChildThreadApprovalsAtom(input: { + readonly childThreadInputsAtom: ( + ref: ScopedThreadRef, + ) => Atom.Atom>; + readonly pendingRequestsAtom: (ref: ScopedThreadRef) => Atom.Atom; +}) { + const family = Atom.family((key: string) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(input.childThreadInputsAtom(parseThreadKey(key))).flatMap((child) => { + if (!child.hasPendingApprovals) return []; + const thread = { environmentId: child.environmentId, threadId: child.id }; + // Only children asking for approval need a detail subscription. + return (get(input.pendingRequestsAtom(thread))?.approvals ?? []).map((approval) => ({ + thread, + title: child.title, + approval, + })); + }); + if ( + next.length === previous.length && + next.every((entry, index) => { + const old = previous[index]!; + return ( + old.approval === entry.approval && + old.title === entry.title && + threadKey(old.thread) === threadKey(entry.thread) + ); + }) + ) + return previous; + previous = next; + return next; + }); + }); + const empty = Atom.make>([]); + return (ref: ScopedThreadRef | null) => (ref === null ? empty : family(threadKey(ref))); +} diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29aac2ae98ecf..0650b1f2093c7 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -5,7 +5,9 @@ import { UsageDay, USAGE_CONTRACT_VERSION, type ServerProvider, + type UsageModelFilter, type UsageSummary, + type UsageSummaryInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -39,7 +41,7 @@ afterEach(() => { for (const registry of registries.splice(0)) registry.dispose(); }); -function harness(ids = ["a"]) { +function harness(ids = ["a"], modelFilters: Record = {}) { const registry = AtomRegistry.make(); registries.push(registry); const environments = ids.map((id) => { @@ -65,13 +67,18 @@ function harness(ids = ["a"]) { if (!environment) throw new Error(`Unknown environment: ${environmentId}`); return environment; } + const requested: { environmentId: EnvironmentId; input: UsageSummaryInput }[] = []; const options = { registry, environmentIds: environments.map((entry) => entry.environmentId), input, server: { - usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => - get(environmentId).query, + usageSummary: (target: { environmentId: EnvironmentId; input: UsageSummaryInput }) => { + requested.push(target); + return get(target.environmentId).query; + }, + usageModelFilterAtom: (environmentId: EnvironmentId) => + Atom.make(modelFilters[environmentId]), refreshUsageRates: { label: "test:rates", requiredScopes: () => [], @@ -86,7 +93,7 @@ function harness(ids = ["a"]) { presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, }, } satisfies Parameters[0]; - return { registry, environments, refresh: () => refreshUsage(options) }; + return { registry, environments, requested, refresh: () => refreshUsage(options) }; } describe("manual usage refresh", () => { @@ -113,6 +120,23 @@ describe("manual usage refresh", () => { expect(finished).toBe(true); }); + it("rescans each environment with its own configured model filter", async () => { + const modelFilter = { include: [{ provider: "claude" as const, model: "orbit-1" }] }; + const { environments, requested, refresh } = harness(["filtered", "plain"], { + filtered: modelFilter, + }); + const refreshing = refresh(); + for (const entry of environments) { + entry.rates.resolve(AsyncResult.success(pricing)); + entry.scan.resolve(summary); + } + await refreshing; + expect(requested).toEqual([ + { environmentId: "filtered", input: { ...input, modelFilter } }, + { environmentId: "plain", input }, + ]); + }); + it("settles when an environment disconnects during the rescan", async () => { const { registry, diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 7bc582eb7da39..2dea725be1f14 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ServerProvider, + UsageModelFilter, UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; @@ -43,6 +44,14 @@ export function cursorKeychainAccessEnvironments< : environments.filter((environment) => environment.needsCursorKeychainAccess); } +/** The Usage tab asks each environment with that environment's configured model filter. */ +export function usageSummaryInput( + input: UsageSummaryInput, + modelFilter: UsageModelFilter | undefined, +): UsageSummaryInput { + return modelFilter === undefined ? input : { ...input, modelFilter }; +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); @@ -86,7 +95,7 @@ export async function refreshUsage({ registry: AtomRegistry.AtomRegistry; server: Pick< ReturnType, - "usageSummary" | "refreshUsageRates" + "usageSummary" | "refreshUsageRates" | "usageModelFilterAtom" >; presentations: Pick, "presentationAtom">; environmentIds: readonly EnvironmentId[]; @@ -94,7 +103,10 @@ export async function refreshUsage({ }): Promise { await Promise.all( environmentIds.map(async (environmentId) => { - const query = server.usageSummary({ environmentId, input }); + const query = server.usageSummary({ + environmentId, + input: usageSummaryInput(input, registry.get(server.usageModelFilterAtom(environmentId))), + }); const presentation = presentations.presentationAtom(environmentId); const controller = new AbortController(); const abortWhenDisconnected = () => { diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 9fa3245df5754..99df810fcdcb1 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -3,7 +3,7 @@ import { decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { threadLinkContextReferences } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; @@ -77,34 +77,46 @@ describe("resolveThreadReferenceCopyTarget", () => { ).toMatchObject({ kind: "pull-request", value: "https://github.com/t3/pr/12" }); }); - it("copies a thread chip that pastes back as the same thread on its machine", () => { - const target = resolveThreadReferenceCopyTarget({ ...thread, linkedPullRequestUrl: null }); + it.each([ + { appScheme: "t3code-unlock", href: "t3code-unlock://threads/env-1/thread-1" }, + { + appScheme: undefined, + href: "[Fix the login flow](t3-thread://v2/env-1/thread-1)", + }, + ])( + "copies a scoped thread using the available app scheme ($appScheme)", + ({ appScheme, href }) => { + const target = resolveThreadReferenceCopyTarget({ + ...thread, + linkedPullRequestUrl: null, + appScheme, + }); - expect(target).toMatchObject({ - kind: "thread", - description: "Fix the login flow", - successTitle: "Thread copied", - }); - const pasted = decodeComposerContextFragment(encodeComposerContextFragment(target!.fragment!)); - expect(pasted).toEqual({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - expect.objectContaining({ - kind: "thread", - environmentId: "env-1", - threadId: "thread-1", - title: "Fix the login flow", - }), - ], - }); - // A paste keeps only the records its plain text references. - expect(collectComposerContextReferences(target!.value)).toEqual([ - expect.objectContaining({ + expect(target).toMatchObject({ kind: "thread", - contextId: pasted!.records[0]!.contextId, - label: "Fix the login flow", - }), - ]); - }); + value: href, + description: "Fix the login flow", + successTitle: "Thread copied", + }); + const pasted = decodeComposerContextFragment( + encodeComposerContextFragment(target!.fragment!), + ); + expect(pasted).toEqual({ + version: 1, + source: { environmentId: "env-1" }, + records: [ + expect.objectContaining({ + kind: "thread", + environmentId: "env-1", + threadId: "thread-1", + title: "Fix the login flow", + }), + ], + }); + expect(threadLinkContextReferences(target!.value, pasted!.records, () => undefined)).toEqual({ + text: `[Fix the login flow](t3-context://v1/thread/${pasted!.records[0]!.contextId})`, + records: pasted!.records, + }); + }, + ); }); diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 94f68202a62cf..d20c212ad8c63 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -3,16 +3,14 @@ import type { ScopedThreadRef, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { - formatComposerContextReference, - threadContextRecord, -} from "@t3tools/shared/composerContextReferences"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { appThreadLink, formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; export interface ThreadReferenceCopyTarget { readonly kind: "pull-request" | "thread"; - /** The plain text: a PR URL, or a thread chip's reference link. */ + /** The plain text: a PR URL, app link, or environment-scoped thread link. */ readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; @@ -27,11 +25,15 @@ export interface ThreadReferenceCopyTarget { export function threadChipCopyTarget( threadRef: ScopedThreadRef, title: string, + scheme?: string, ): ThreadReferenceCopyTarget { const record = threadContextRecord(threadRef, title); return { kind: "thread", - value: formatComposerContextReference(record), + value: + scheme === undefined + ? formatThreadLink(threadRef, record.title) + : appThreadLink(threadRef, scheme), description: record.title, fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", @@ -47,6 +49,7 @@ export function resolveThreadReferenceCopyTarget(input: { readonly openPanelPullRequestUrl?: string | null | undefined; readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequestUrl?: string | null; + readonly appScheme?: string | undefined; }): ThreadReferenceCopyTarget | null { if (input.openPanelPullRequestUrl === null) return null; const pullRequestUrl = @@ -62,5 +65,5 @@ export function resolveThreadReferenceCopyTarget(input: { successTitle: "PR link copied", failureTitle: "Failed to copy PR link", } - : threadChipCopyTarget(input.threadRef, input.title); + : threadChipCopyTarget(input.threadRef, input.title, input.appScheme); } diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 80f1156663a0b..8298891c5c170 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("keeps one-time schedules opt-in for older environments", () => { + expect(decodeDescriptor(descriptor).capabilities.oneTimeSchedules).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, oneTimeSchedules: true }, + }).capabilities.oneTimeSchedules, + ).toBe(true); + }); + it("decodes old, recognized and future manual installation descriptors", () => { expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined(); for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 0af0a0d8891ed..d2b77c3e1678d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -15,6 +15,11 @@ export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; export const ORCHESTRATION_PROTOCOL_HEADER = "x-t3-orchestration-protocol"; +/** Wire shapes a client understands, comma-separated; a client that omits one gets the older shape. */ +export const CLIENT_CAPABILITIES_QUERY_PARAM = "clientCapabilities"; +/** Secret fields may hold a secret provider reference instead of a string. */ +export const SETTINGS_SECRET_REFERENCES_CAPABILITY = "settingsSecretReferences"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", @@ -169,6 +174,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ linkFence: Schema.optionalKey(Schema.Boolean), /** Server answers server.prepareTriage. */ triage: Schema.optionalKey(Schema.Boolean), + /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ + oneTimeSchedules: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f732a7cc1ba4b..b704de0ddc714 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1484,6 +1484,13 @@ export const OrchestrationV2UserMessageInputIntent = Schema.Literals([ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; +/** A same-harness model or effort change that took effect with this message. */ +export const OrchestrationV2ModelSwitch = Schema.Struct({ + from: ModelSelection, + to: ModelSelection, +}); +export type OrchestrationV2ModelSwitch = typeof OrchestrationV2ModelSwitch.Type; + const OrchestrationV2TurnItemBaseFields = { /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ deferredWork: Schema.optionalKey( @@ -1605,6 +1612,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ textOmitted: DedupedBodyMarker, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2438,6 +2446,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ text: Schema.String, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 1474a5d6e87b6..305c1feecac76 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -67,7 +67,7 @@ const OrchestratorMcpSchedule = Schema.Union([ OrchestratorMcpScheduleFromJsonString, ]).annotate({ description: - "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, or {type:'webhook'}.", + "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, {type:'once',runAt: ISO timestamp with offset}, or {type:'webhook'}.", }); const OrchestratorMcpTargetOptionsFromRecord = Schema.Record( diff --git a/packages/contracts/src/scheduledTask.test.ts b/packages/contracts/src/scheduledTask.test.ts index 50a49de7f867d..c9b4d597bd0a3 100644 --- a/packages/contracts/src/scheduledTask.test.ts +++ b/packages/contracts/src/scheduledTask.test.ts @@ -22,6 +22,22 @@ describe("ScheduledTaskSchedule", () => { everyMs: MIN_SCHEDULED_TASK_INTERVAL_MS - 1, }); }); + + it("accepts a one-time run only with an offset", () => { + for (const runAt of ["2026-11-03T09:00Z", "2026-11-03T09:00:00.000-08:00"]) { + expect(decodeUpsertSchedule({ type: "once", runAt })).toEqual({ type: "once", runAt }); + } + for (const runAt of [ + "2026-11-03T09:00", + "2026-13-03T09:00Z", + "2026-02-30T09:00Z", + "9999-12-31T23:30-01:00", + "0000-01-01T00:00+00:01", + "tomorrow", + ]) { + expect(() => decodeUpsertSchedule({ type: "once", runAt })).toThrow(); + } + }); }); it.each(["literal", " ", "\t\n", "{{body.bad", "{{unknown.field}}"])( diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index b4dffacd74c04..5fbbc48301d3a 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -45,6 +45,34 @@ const ScheduledTaskFixedTimeSchedule = Schema.Struct({ ), }); +const ISO_WITH_OFFSET = + /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; + +const YEAR_0000 = Date.parse("0000-01-01T00:00:00Z"); +const YEAR_10000 = Date.parse("+010000-01-01T00:00:00Z"); + +/** Date.parse rolls impossible days like Feb 30 into the next month, so check the day too. */ +function isIsoWithOffset(value: string): boolean { + const match = ISO_WITH_OFFSET.exec(value); + // The server stores UTC, which must stay a four-digit year to decode again. + const time = Date.parse(value); + if (!match || !(time >= YEAR_0000 && time < YEAR_10000)) return false; + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; + return days !== undefined && day >= 1 && day <= days; +} + +// Checked by filter, not pattern, to keep the MCP tool schema small. +const ScheduledTaskOnceSchedule = Schema.Struct({ + type: Schema.Literal("once"), + runAt: TrimmedNonEmptyString.check( + Schema.makeFilter( + (value) => isIsoWithOffset(value) || "Expected an ISO date and time with an offset.", + ), + ), +}); + const ScheduledTaskWebhookSignatureFields = { header: TrimmedNonEmptyString.annotate({ description: "Header with the signature, such as x-hub-signature-256.", @@ -166,6 +194,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ export const ScheduledTaskSchedule = Schema.Union([ ScheduledTaskIntervalSchedule, ScheduledTaskFixedTimeSchedule, + ScheduledTaskOnceSchedule, ScheduledTaskWebhookSchedule, ]); export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type; @@ -180,6 +209,7 @@ export const ScheduledTaskUpsertSchedule = Schema.Union([ }), }), ScheduledTaskFixedTimeSchedule, + ScheduledTaskOnceSchedule, ScheduledTaskUpsertWebhookSchedule, ]); export type ScheduledTaskUpsertSchedule = typeof ScheduledTaskUpsertSchedule.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c749881434819..33c5392fd1706 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -644,6 +644,8 @@ export const ServerConfig = Schema.Struct({ secretProviderSettings: Schema.optionalKey(Schema.Boolean), /** Secret settings may point at a secret provider; older servers drop the reference. */ secretReferences: Schema.optionalKey(Schema.Boolean), + /** Host-token maps and usage-limit keys accept secret provider references. */ + settingsSecretReferences: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 44355c4062d00..9f11642a5e188 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -139,6 +139,17 @@ describe("ServerSettings usage price overrides", () => { expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow(); } }); + + it("ignores an invalid model filter without dropping the other settings", () => { + const valid = { include: [{ provider: "newer-provider", model: "orbit-1" }] }; + expect(decodeServerSettings({ usageModelFilter: valid }).usageModelFilter).toEqual(valid); + const settings = decodeServerSettings({ + usageModelFilter: { exclude: [{ model: "" }] }, + usageModelAliases: { "drift-9": "orbit-1" }, + }); + expect(settings.usageModelFilter).toBeUndefined(); + expect(settings.usageModelAliases).toEqual({ "drift-9": "orbit-1" }); + }); }); describe("ServerSettings secret providers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e5c826cabc720..753b1c6c7b49e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,7 +23,9 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; +import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -875,7 +877,10 @@ export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, - managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), + managementKey: SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + ), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; @@ -1392,6 +1397,9 @@ export const ServerSettings = Schema.Struct({ usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + + /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. Invalid values are ignored. */ + usageModelFilter: ForwardCompatibleOptional(UsageModelFilter), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1698,7 +1706,7 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty or omitted secrets remove them. */ + /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty secrets remove them. A non-empty token map keeps the saved tokens of hosts it omits; an empty map clears them all. */ sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Replace whole maps to avoid half-merged driver configs. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), diff --git a/packages/contracts/src/settingsSecretValue.ts b/packages/contracts/src/settingsSecretValue.ts index 101c40998b6f0..97558c82d574f 100644 --- a/packages/contracts/src/settingsSecretValue.ts +++ b/packages/contracts/src/settingsSecretValue.ts @@ -26,26 +26,68 @@ export function acceptsSecretReference(schema: Schema.Top): boolean { return Schema.resolveAnnotationsKey(schema)?.settingsSecretReference === true; } -/** Fields of a settings struct typed `SettingsSecretValue`; any other schema has none. */ -function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { +const declaresSecretReference = (ast: SchemaAST.AST) => + ast.context?.annotations?.settingsSecretReference === true; + +interface SecretReferenceField { + readonly field: string; + readonly keyed: boolean; +} + +/** + * Fields of a settings struct typed `SettingsSecretValue`, or a record of it such as one value + * per host; any other schema has none. + */ +function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { if (!SchemaAST.isObjects(schema.ast)) return []; - return schema.ast.propertySignatures.flatMap((property) => - property.type.context?.annotations?.settingsSecretReference === true && - typeof property.name === "string" - ? [property.name] - : [], - ); + return schema.ast.propertySignatures.flatMap(({ name, type }) => { + if (typeof name !== "string") return []; + if (declaresSecretReference(type)) return [{ field: name, keyed: false }]; + return SchemaAST.isObjects(type) && + type.indexSignatures.some((signature) => declaresSecretReference(signature.type)) + ? [{ field: name, keyed: true }] + : []; + }); } -/** Each declared reference field of `settings` that holds a reference, in field order. */ +/** Where a reference sits: a field, or one key of a record field. */ +export interface SettingsSecretSlot { + readonly field: string; + readonly key: string | undefined; +} + +interface SettingsSecretReference extends SettingsSecretSlot { + readonly reference: SecretReference; +} + +/** Each declared reference slot of `settings` that holds a reference, in field order. */ export function secretReferencesInSettings( schema: Schema.Top, settings: unknown, -): ReadonlyArray<{ readonly field: string; readonly reference: SecretReference }> { +): ReadonlyArray { if (settings === null || typeof settings !== "object") return []; const values = settings as Readonly>; - return secretReferenceSettingsFields(schema).flatMap((field) => { - const value = values[field]; - return isSecretReference(value) ? [{ field, reference: value }] : []; - }); + return secretReferenceSettingsFields(schema).flatMap( + ({ field, keyed }) => { + const value = values[field]; + if (!keyed) + return isSecretReference(value) ? [{ field, key: undefined, reference: value }] : []; + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, entry]) => + isSecretReference(entry) ? [{ field, key, reference: entry }] : [], + ); + }, + ); +} + +/** `settings` with each slot set to its value, keeping the other keys of a record field. */ +export function withSettingsSecrets>>( + settings: Settings, + slots: ReadonlyArray, +): Settings { + const next: Record = { ...settings }; + for (const { field, key, value } of slots) { + next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value }; + } + return next as Settings; } diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 2fd82d8fab795..28fe3dfb169df 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -111,6 +111,23 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +/** + * Matches a provider's original model ID, before usage model aliases apply. Omit provider to + * match any; a string provider lets older clients carry selectors for providers they lack. + */ +export const UsageModelSelector = Schema.Struct({ + provider: Schema.optional(TrimmedNonEmptyString), + model: TrimmedNonEmptyString, +}); +export type UsageModelSelector = typeof UsageModelSelector.Type; + +/** Keeps records matching `include` (all when omitted) and not matching `exclude`. */ +export const UsageModelFilter = Schema.Struct({ + include: Schema.optional(Schema.Array(UsageModelSelector)), + exclude: Schema.optional(Schema.Array(UsageModelSelector)), +}); +export type UsageModelFilter = typeof UsageModelFilter.Type; + export const UsageSummaryInput = Schema.Struct({ sinceDay: UsageDay, untilDay: UsageDay, @@ -120,6 +137,7 @@ export const UsageSummaryInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), untilTime: Schema.optional(TrimmedNonEmptyString), awaitRefresh: Schema.optional(Schema.Boolean), + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; @@ -133,6 +151,8 @@ export const UsageSummary = Schema.Struct({ sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, scanDurationMs: NonNegativeInt, + /** The filter the server applied; absent means unfiltered, including servers that predate filters. */ + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummary = typeof UsageSummary.Type; diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts index 30f6826278c7a..85cac547cfa37 100644 --- a/packages/provider-core/src/server/notification.ts +++ b/packages/provider-core/src/server/notification.ts @@ -217,6 +217,7 @@ export function notificationTurnItem( createdBy: _createdBy, creationSource: _creationSource, scheduledTaskId: _scheduledTaskId, + modelSwitch: _modelSwitch, ...base } = item; return { ...base, type: "notification", ...notification }; diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 10dc9e4ce579f..4e992d93cabf8 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -9,7 +9,7 @@ Ordinary coding tasks need no T3 tools. - A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. It lists a short set of models per provider; pass \`search\` to find others. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited, and the child finishes in this turn (Claude \`Agent\` always with \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider, background, or explicitly T3-owned child tasks. For a child task that needs a separate checkout, use \`delegate_task\` with \`isolation:"worktree"\` across any provider or model. It starts from the parent local commit and runs project setup; uncommitted edits are not copied. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, - `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. + `- \`schedule_task\` creates persistent scheduled work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, \`{"type":"once","runAt":"2026-12-01T09:00:00-06:00"}\` for a one-time run, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. - To mention another thread, use the returned environment-scoped link verbatim. For legacy local references, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`, `### Read shared conversation context diff --git a/packages/secret-provider-core/src/server/SecretResolver.test.ts b/packages/secret-provider-core/src/server/SecretResolver.test.ts index 573fcfa4f655a..b2b3356472b2b 100644 --- a/packages/secret-provider-core/src/server/SecretResolver.test.ts +++ b/packages/secret-provider-core/src/server/SecretResolver.test.ts @@ -31,12 +31,13 @@ const ref = (reference: string, provider = personal): SecretReference => ({ prov function fakeDriver( answer: (account: string, reference: string) => string | undefined, wait: (references: ReadonlyArray) => Effect.Effect = () => Effect.void, + kind = FAKE_KIND, ) { const reads: Array<{ readonly account: string; readonly references: ReadonlyArray }> = []; const driver = defineSecretProviderDriver({ - kind: FAKE_KIND, + kind, definition: defineSecretProvider({ - kind: FAKE_KIND, + kind, label: "Fake", configSchema: FakeConfig, isReference: (reference) => reference.startsWith("fake://"), @@ -176,6 +177,28 @@ describe("SecretResolver", () => { }).pipe(Effect.provide(resolverLayer(driver, () => ({ [personal]: fake(account) })))); }); + it.effect("reads again when a provider switches drivers with the same config", () => { + const otherKind = SecretProviderKind.make("other"); + let provider = fake("a"); + const first = fakeDriver(() => "first-key"); + const second = fakeDriver(() => "second-key", undefined, otherKind); + return Effect.gen(function* () { + const resolver = yield* SecretResolver.SecretResolver; + const token = ref("fake://openai"); + + assert.deepStrictEqual(yield* resolver.read([token]), ["first-key"]); + provider = { ...provider, kind: otherKind }; + assert.deepStrictEqual(yield* resolver.read([token]), ["second-key"]); + }).pipe( + Effect.provide( + SecretResolver.layer({ + drivers: [first.driver, second.driver], + secretProviders: Effect.sync(() => ({ [personal]: provider })), + }).pipe(Layer.provide(NodeServices.layer)), + ), + ); + }); + it.effect("does not call a driver for what it cannot read, and reads it later", () => { const { driver, reads } = fakeDriver(() => "sk-live"); const providers: ServerSettings["secretProviders"] = { diff --git a/packages/secret-provider-core/src/server/SecretResolver.ts b/packages/secret-provider-core/src/server/SecretResolver.ts index d7b68c316a8a1..ca0417bcbc8cd 100644 --- a/packages/secret-provider-core/src/server/SecretResolver.ts +++ b/packages/secret-provider-core/src/server/SecretResolver.ts @@ -1,6 +1,5 @@ // Reads secrets that settings point at through a configured secret provider. Each read, including -// a failed one, is held for the server's life so rebuilds never re-prompt an unlock; `invalidate` -// (the Settings refresh) is the only way to read again. +// a failed one, is cached until the provider changes or Settings refresh invalidates it. import type { SecretReference, ServerSettings } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -14,9 +13,10 @@ import type { SecretProviderDriver, SecretProviderDriverServices } from "./drive export class SecretResolver extends Context.Service< SecretResolver, { - /** Values in input order; `undefined` marks a reference that could not be read. */ + /** Reads from the supplied settings snapshot when present, returning values in input order. */ readonly read: ( references: ReadonlyArray, + providersSnapshot?: ServerSettings["secretProviders"], ) => Effect.Effect>; /** Forgets the given references, or every reference, so the next read asks the provider again. */ readonly invalidate: (references?: ReadonlyArray) => Effect.Effect; @@ -27,8 +27,8 @@ const cacheKey = (reference: SecretReference) => `${reference.provider}\u0000${reference.reference}`; interface CachedSecret { - // A provider whose config changed, such as another account, reads again. - readonly config: string; + // A change to the driver or its config requires a fresh read. + readonly configKey: string; readonly value: string | undefined; } @@ -68,11 +68,11 @@ export const make = Effect.fn("SecretResolver.make")(function* (options: { const config = decodeConfig(provider.config); return Option.isNone(config) ? undefined - : { driver, config: config.value, configKey: JSON.stringify(config.value) }; + : { driver, config: config.value, configKey: JSON.stringify([provider.kind, config.value]) }; }; const isCached = (reference: SecretReference, plan: ReadablePlan) => - cache.get(cacheKey(reference))?.config === plan.configKey; + cache.get(cacheKey(reference))?.configKey === plan.configKey; const answer = ( references: ReadonlyArray, @@ -88,8 +88,9 @@ export const make = Effect.fn("SecretResolver.make")(function* (options: { // Cache hits answer without the lock, so a read waiting on an unlock never stalls a cached one. const read: SecretResolver["Service"]["read"] = Effect.fn("SecretResolver.read")(function* ( references, + providersSnapshot, ) { - const providers = yield* options.secretProviders; + const providers = providersSnapshot ?? (yield* options.secretProviders); const plans = references.map((reference) => planFor(providers, reference)); const isMissing = (reference: SecretReference, index: number) => { const plan = plans[index]; @@ -120,7 +121,7 @@ export const make = Effect.fn("SecretResolver.make")(function* (options: { const values = yield* plan.driver.read(plan.config, [...byReference.keys()]); [...byReference.values()].forEach((sameReference, index) => { for (const reference of sameReference) { - cache.set(cacheKey(reference), { config: plan.configKey, value: values[index] }); + cache.set(cacheKey(reference), { configKey: plan.configKey, value: values[index] }); } }); } diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index a90312bc5c432..2539a78f7fa5b 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -17,6 +17,7 @@ import { stripAttachmentOnlyBootstrapPrompt, threadContextRecord, threadContextsOutsideEnvironment, + threadLinkContextReferences, toComposerContextId, toKindScopedComposerContextId, } from "./composerContextReferences.ts"; @@ -441,3 +442,64 @@ describe("threadContextRecord", () => { ).toBe("thread_abc-123"); }); }); + +describe("threadLinkContextReferences", () => { + const thread = (environmentId: string, threadId: string) => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }); + const reference = (environmentId: string, threadId: string, label: string) => + `[${label}](t3-context://v1/thread/${threadContextRecord(thread(environmentId, threadId), label).contextId})`; + + it("makes a chip of a copied link, a v2 link, and a labeled link, titled here", () => { + const pasted = threadLinkContextReferences( + [ + "t3code-unlock://threads/env-1/t1", + "", + "[Written](t3code-unlock://threads/env-2/t3)", + ].join(" "), + [], + ({ threadId }) => (threadId === "t3" ? undefined : `Title ${threadId}`), + ); + expect(pasted.text).toBe( + [ + reference("env-1", "t1", "Title t1"), + reference("env-1", "t2", "Title t2"), + reference("env-2", "t3", "Written"), + ].join(" "), + ); + expect(pasted.records).toEqual([ + threadContextRecord(thread("env-1", "t1"), "Title t1"), + threadContextRecord(thread("env-1", "t2"), "Title t2"), + threadContextRecord(thread("env-2", "t3"), "Written"), + ]); + }); + + it("prefers the record the copy carried, and names an unknown thread plainly", () => { + const copied = threadContextRecord(thread("env-1", "t1"), "Copied title"); + const pasted = threadLinkContextReferences( + "t3code-unlock://threads/env-1/t1 t3code-unlock://threads/env-9/t9", + [copied], + () => "Here", + ); + expect(pasted.records).toEqual([copied, threadContextRecord(thread("env-9", "t9"), "Here")]); + expect( + threadLinkContextReferences("t3code-unlock://threads/env-9/t9", [], () => undefined).records, + ).toEqual([threadContextRecord(thread("env-9", "t9"), "Thread")]); + }); + + it("keeps links it may not chip, v1 links, and links in code as written", () => { + const text = [ + "t3code-unlock://threads/elsewhere/t1", + "[Old](t3-thread://v1/t2)", + "`t3code-unlock://threads/env-1/t3`", + ].join(" "); + const pasted = threadLinkContextReferences( + text, + [], + () => "Title", + () => undefined, + ); + expect(pasted).toEqual({ text, records: [] }); + }); +}); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 85ba03e973b2c..7f3e10e84a3fe 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -10,6 +10,8 @@ import { type ThreadContextRecord, } from "@t3tools/contracts"; +import { replaceThreadLinks, threadLinkCandidates } from "./threadLinks.ts"; + const CONTEXT_PROTOCOL = "t3-context:"; const COMPOSER_CONTEXT_HREF_PREFIX = `${CONTEXT_PROTOCOL}//v1/`; const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/; @@ -89,6 +91,45 @@ export function threadContextRecord(ref: ScopedThreadRef, title: string): Thread }; } +/** + * Turns pasted thread links into thread chip references. A record the copy carried names its + * chip, else the thread's title here, read by key, or the link's own label. `accept` gives the + * chip's id, or none to keep the link as written. A v1 link names no machine, so it stays a link. + */ +export function threadLinkContextReferences( + text: string, + copied: ReadonlyArray, + title: (ref: ScopedThreadRef) => string | undefined, + accept: (record: ThreadContextRecord) => ComposerContextId | undefined = (record) => + record.contextId, +): { readonly text: string; readonly records: ReadonlyArray } { + const records = new Map(); + const rewritten = replaceThreadLinks(text, (link, label) => { + if (link.environmentId === null) return undefined; + const candidates = threadLinkCandidates(link, link.environmentId); + const record = + copied.find( + (candidate): candidate is ThreadContextRecord => + candidate.kind === "thread" && + "threadId" in candidate && + candidates.some( + (ref) => + ref.environmentId === candidate.environmentId && ref.threadId === candidate.threadId, + ), + ) ?? + candidates.flatMap((ref) => { + const known = title(ref); + return known === undefined ? [] : [threadContextRecord(ref, known)]; + })[0] ?? + threadContextRecord(candidates[0]!, label?.trim() || "Thread"); + const contextId = accept(record); + if (contextId === undefined) return undefined; + records.set(record.contextId, record); + return formatComposerContextReference({ ...record, contextId }); + }); + return { text: rewritten, records: [...records.values()] }; +} + export function formatComposerContextReference(reference: { kind: ComposerContextKind; contextId: ComposerContextId; diff --git a/packages/shared/src/promptLinks.test.ts b/packages/shared/src/promptLinks.test.ts index afc5644f9ac3d..64f6888738a93 100644 --- a/packages/shared/src/promptLinks.test.ts +++ b/packages/shared/src/promptLinks.test.ts @@ -108,6 +108,9 @@ describe("link round trips", () => { it("maps app scheme and hosted links to in-app paths, and nothing else", () => { expect(promptLinkAppPath("t3code://new?prompt=hi")).toBe("/new?prompt=hi"); expect(promptLinkAppPath("t3code-dev://threads/env-1/thread-1")).toBe("/env-1/thread-1"); + expect(promptLinkAppPath("t3code-unlock://threads/remote/folder%252Fthread")).toBe( + "/remote/folder%252Fthread", + ); expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe( "/env-1/thread-1?prompt=go", ); diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 26cbfb3df9d59..c795d2a4d5455 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -121,6 +121,24 @@ describe("serverSettings helpers", () => { }); }); + it("keeps a source control host's saved tokens when a patch sets another host's token", () => { + const reference = { provider: "vault", reference: "github/token" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + sourceControlHosts: { github: { tokens: { "github.com": reference } } }, + }); + const patched = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { "ghe.example": "x" } } }, + }); + expect(patched.sourceControlHosts.github?.tokens).toEqual({ + "github.com": reference, + "ghe.example": "x", + }); + const recased = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { " GitHub.com ": "ghp_new" } } }, + }); + expect(recased.sourceControlHosts.github?.tokens).toEqual({ "github.com": "ghp_new" }); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index fbd6447f52bed..075d3d3a4dd72 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -23,6 +23,7 @@ import { withIdleCompaction, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; @@ -226,6 +227,15 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +// Secret persistence keys tokens by trimmed, lowercased host, so merge on the same key. +const byHost = (tokens: unknown) => + Object.fromEntries( + Object.entries(Predicate.isObject(tokens) ? tokens : {}).map(([host, token]) => [ + host.trim().toLowerCase(), + token, + ]), + ); + const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { @@ -537,10 +547,26 @@ export function applyServerSettingsPatch( sourceControlHosts: { ...current.sourceControlHosts, ...Object.fromEntries( - Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ - kind, - { ...current.sourceControlHosts[kind], ...fields }, - ]), + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => { + const saved = current.sourceControlHosts[kind]; + return [ + kind, + { + ...saved, + ...fields, + // Older clients send the whole token map; a host missing from it keeps its saved + // token. An empty map still clears them all. + ...(Predicate.isObject(fields.tokens) && Object.keys(fields.tokens).length > 0 + ? { + tokens: { + ...byHost(saved?.tokens), + ...byHost(fields.tokens), + }, + } + : {}), + }, + ]; + }), ), }, } diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 5848861f47ac4..64a509e7e1f5f 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -134,10 +134,7 @@ const T3_MCP_TOOLS: Readonly> = { ["Cancel", "Canceling", "Requested cancellation of", "a delegated task"], "task-cancel", ), - schedule_task: tool( - ["Schedule", "Scheduling", "Scheduled", "a recurring task"], - "schedule-create", - ), + schedule_task: tool(["Schedule", "Scheduling", "Scheduled", "a task"], "schedule-create"), list_scheduled_tasks: tool(["List", "Listing", "Listed", "scheduled tasks"], "schedule-list"), update_scheduled_task: tool( ["Update", "Updating", "Updated", "a scheduled task"], diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index bda540727c1d9..e6d0f0c155a90 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { + appThreadLink, formatThreadLink, guessThreadLinkRef, parseThreadLinkHref, @@ -181,3 +182,24 @@ describe("thread links", () => { expect(formatThreadLink(ref("e", "t1"), " ] ")).toBe(""); }); }); + +it("keeps copied app links exact and scoped without a network origin", () => { + const ref = { + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("folder%2Fthread"), + }; + const href = appThreadLink(ref, "t3code-unlock"); + expect(href).toBe("t3code-unlock://threads/remote/folder%252Fthread"); + expect(parseThreadReference(href)).toEqual({ ...ref, exact: true }); + expect(relabelThreadLinks(href, ref.environmentId, () => "Current title")).toBe( + `[Current title](${href})`, + ); + expect( + threadLinkRefs( + `Open ${href}. +\`${href.replace("remote", "hidden")}\``, + ref.environmentId, + ), + ).toEqual([ref]); + expect(parseThreadLinkHref("https://app.t3.codes/threads/remote/folder%252Fthread")).toBeNull(); +}); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 66686e0d4922c..64f7ed3ce18cd 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -5,9 +5,17 @@ import * as Schema from "effect/Schema"; export const THREAD_LINK_PROTOCOL = "t3-thread"; const V1_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`; -// Code comes first in the alternation so a link written inside a code span or fence is skipped. -const THREAD_LINK_OUTSIDE_CODE = - /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[(?