diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index c74a0dc48ff..c3d617665fe 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -15,6 +15,7 @@ github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev +github:gfsaaser24 github:github-actions[bot] github:hwanseoc github:jamesx0416 @@ -26,6 +27,7 @@ github:Noojuno github:notkainoa github:PatrickBauer github:realAhmedRoach +github:saphid github:shiroyasha9 github:StiensWout github:Yash-Singh1 diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml new file mode 100644 index 00000000000..f9cc3b063fc --- /dev/null +++ b/.github/workflows/web-preview.yml @@ -0,0 +1,132 @@ +name: Web Preview + +# Label a PR `preview:web` to get a hosted-web preview deployment on Vercel for +# that push and every subsequent push. The deployment is a plain (non-prod, +# non-aliased) deploy into the existing hosted-web Vercel project, so the +# latest/nightly channel aliases are never touched. +# +# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay +# URL): previews boot as the hosted-static app with manual pairing only. Pair a +# server into a preview with `t3 pair --tailscale` (or any reachable HTTPS +# backend) and open the pairing URL against the preview origin. +# +# The preview must be opened at the exact deployment URL from the PR comment. +# Vite bakes that URL in as the hosted origin (via VERCEL_URL), and +# `isHostedStaticApp` matches on origin, so branch-alias URLs will not +# self-identify as the hosted app. + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: web-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy web preview + # Same-repo PRs only: fork PRs do not receive the Vercel secrets, and this + # workflow should skip rather than fail for them. On `labeled` events, only + # the preview label itself triggers a deploy. + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'preview:web') && + (github.event.action != 'labeled' || github.event.label.name == 'preview:web') + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/web... + + - id: deploy + name: Deploy preview + shell: bash + run: | + set -euo pipefail + + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then + echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 + exit 1 + fi + + vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" + + deployment_url="$( + vp dlx vercel@53.1.1 deploy \ + --archive=tgz \ + --yes \ + --token "$VERCEL_TOKEN" \ + --scope "$vercel_scope" + )" + + echo "Deployed $deployment_url" + echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" + + - name: Comment deployment URL + uses: actions/github-script@v8 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const marker = ""; + const body = [ + marker, + "### Web preview", + "", + `${process.env.DEPLOYMENT_URL} (for ${process.env.HEAD_SHA.slice(0, 7)})`, + "", + "Open this exact URL — the hosted-app origin is baked in at build time.", + "Pair a server into it with `t3 pair --tailscale`, or paste a host + pairing", + "code under Settings → Connections.", + ].join("\n"); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 92d7a1695f5..1b9b50381eb 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -48,7 +48,10 @@ "status": "implemented", "summary": "The canonical Turbo icon and registered web, desktop, mobile, marketing, and widget derivatives remain present.", "checks": [ - { "path": "t3turbo.png", "markers": [] }, + { + "path": "t3turbo.png", + "markers": [] + }, { "path": "scripts/lib/turbo-brand-assets.ts", "markers": [ @@ -65,13 +68,34 @@ "path": "scripts/export-turbo-icons.ts", "markers": ["TURBO_BRAND_ASSET_PATHS", "--check"] }, - { "path": "assets/turbo/t3turbo-windows.ico", "markers": [] }, - { "path": "assets/turbo/t3turbo.icns", "markers": [] }, - { "path": "assets/turbo/t3turbo-ios-1024.png", "markers": [] }, - { "path": "assets/turbo/t3turbo-web-favicon.ico", "markers": [] }, - { "path": "apps/mobile/assets/t3turbo-android-monochrome.png", "markers": [] }, - { "path": "apps/mobile/assets/t3turbo-android-notification.png", "markers": [] }, - { "path": "apps/mobile/assets/widget/T3Mark.png", "markers": [] }, + { + "path": "assets/turbo/t3turbo-windows.ico", + "markers": [] + }, + { + "path": "assets/turbo/t3turbo.icns", + "markers": [] + }, + { + "path": "assets/turbo/t3turbo-ios-1024.png", + "markers": [] + }, + { + "path": "assets/turbo/t3turbo-web-favicon.ico", + "markers": [] + }, + { + "path": "apps/mobile/assets/t3turbo-android-monochrome.png", + "markers": [] + }, + { + "path": "apps/mobile/assets/t3turbo-android-notification.png", + "markers": [] + }, + { + "path": "apps/mobile/assets/widget/T3Mark.png", + "markers": [] + }, { "path": "apps/mobile/app.config.ts", "markers": [ @@ -435,10 +459,6 @@ "path": "apps/web/src/components/Sidebar.tsx", "markers": ["openChatPaneTarget({ kind: \"server\", threadRef }, chatPaneSide)"] }, - { - "path": "apps/web/src/components/SidebarV2.tsx", - "markers": ["openChatPaneTarget({ kind: \"server\", threadRef }, chatPaneSide)"] - }, { "path": "apps/web/src/turbo/chatPanes/chatPaneContextMenu.test.ts", "markers": ["offers the right and left split actions"] diff --git a/.t3-turbo/upstream.json b/.t3-turbo/upstream.json index 52d58fdb5e5..4913533cf20 100644 --- a/.t3-turbo/upstream.json +++ b/.t3-turbo/upstream.json @@ -1,8 +1,8 @@ { "repository": "pingdotgg/t3code", "branch": "main", - "mainSha": "239ef1c54df2f657912ccb5b8e25193d49d90417", - "nightlyTag": "v0.0.32-nightly.20260807.1023", - "nightlySha": "23f0a1ae38cb1fc510a499ef2b3602f5ae98d0c9", - "version": "0.0.32-nightly.20260803.986" + "mainSha": "1a003e383ac6b10258b8100c2617d938c4f06c69", + "nightlyTag": "v0.0.33-nightly.20260809.1042", + "nightlySha": "963ebf5bd7cce00d40ff60c258b34c12dcab271e", + "version": "0.0.33-nightly.20260809.1042" } diff --git a/README.md b/README.md index 5dc40335383..48bdf6c7625 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) +- [Customize a project icon](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1516b7cbc73..da1be88a8bd 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { @@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsUsage: createNativeStackScreen({ + screen: UsageRouteScreen, + linking: "usage", + options: { + title: "Usage", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index b1e83607d8e..89dc0cc045b 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -14,6 +14,7 @@ import { IconBellRinging, IconBolt, IconCamera, + IconChartBar, IconCheck, IconChevronDown, IconCode, @@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, camera: IconCamera, + "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index d52aa05b446..c4297f24b09 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -24,13 +24,18 @@ export function ProjectFavicon(props: { readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null; + readonly faviconPath?: string | null; }) { const size = props.size ?? 42; const faviconUrl = useAssetUrl( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null - : { _tag: "project-favicon", cwd: props.workspaceRoot }, + : { + _tag: "project-favicon", + cwd: props.workspaceRoot, + ...(props.faviconPath ? { path: props.faviconPath } : {}), + }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; const cacheKey = diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 01440007bc6..801862086b9 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -368,6 +368,7 @@ function ProjectGroupLabel(props: { + ); } diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index df012c90325..7189fdc2ebe 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -3,6 +3,7 @@ export type SettingsSheetTarget = | "SettingsArchive" | "SettingsAppearance" | "SettingsProjectGrouping" - | "SettingsClientStorage"; + | "SettingsClientStorage" + | "SettingsUsage"; export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index bc044d905b4..7f4a68c08c7 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -296,6 +296,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; + return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; + }, [t3ProjectFileData]); + const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFile: t3ProjectFileDefaultMode, + globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + }); + // While unsettled the resolved default is provisional. Nothing may write + // it into the draft during that window (the auto-branch effect does), or + // the frozen interim value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ + explicitMode: selectedProjectDraft.workspaceSelection?.mode, + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFilePending: t3ProjectFileQuery.isPending, + }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; @@ -618,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, [refreshBranches, selectedProject]); useEffect(() => { - if (workspaceMode !== "worktree" || selectedBranchName !== null) { + if ( + !defaultWorkspaceModeSettled || + workspaceMode !== "worktree" || + selectedBranchName !== null + ) { return; } // The default may only exist as origin/ (isRemote), which @@ -630,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (preferredBranch) { selectBranch(preferredBranch); } - }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [ + allBranchRefs, + availableBranches, + defaultWorkspaceModeSettled, + selectBranch, + selectedBranchName, + workspaceMode, + ]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 855713946ff..8f32df5c726 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -134,6 +134,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: > { + return buildChartDays(days, daily, metric).flatMap((day) => + day.values.map((entry) => ({ + x: day.day, + y: entry.value, + color: colors[entry.provider], + })), + ); + }, [days, daily, metric, colors]); + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageDailyChart.tsx b/apps/mobile/src/features/usage/UsageDailyChart.tsx new file mode 100644 index 00000000000..55e2de31572 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageDailyChart.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { View } from "react-native"; + +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { buildChartDays, type UsageChartMetric } from "./usageChartData"; +import { useProviderColors } from "./usageProviders"; + +export interface UsageDailyChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; + readonly height: number; +} + +/** + * Stacked daily bars drawn with plain views. Android and any platform without + * Swift Charts land here; iOS resolves `UsageDailyChart.ios.tsx` instead. + */ +export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { + const colors = useProviderColors(); + const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]); + const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0); + + return ( + + {/* column-reverse stacks the bottom-first provider values upward + without reversing the array (Hermes lacks Array#toReversed). */} + {chartDays.map((day) => ( + + {day.values.map((entry) => ( + + ))} + + ))} + + ); +} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx new file mode 100644 index 00000000000..54a9ac7bc21 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -0,0 +1,442 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MergedUsage } from "@t3tools/shared/usageMerge"; +import { + enumerateDays, + formatCount, + formatDayShort, + formatPercent, + formatTokens, + formatUsd, + makeWindow, +} from "@t3tools/shared/usageFormat"; +import { useMemo, useState } from "react"; +import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { UsageDailyChart } from "./UsageDailyChart"; +import type { UsageChartMetric } from "./usageChartData"; +import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; + +const WINDOW_OPTIONS = [ + { days: 7, label: "7 days" }, + { days: 30, label: "30 days" }, + { days: 90, label: "90 days" }, +] as const; + +const CHART_HEIGHT = 180; + +export function UsageRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [windowDays, setWindowDays] = useState(30); + const [metric, setMetric] = useState("cost"); + + // Recomputed only when the window length changes, so a re-render does not + // shift the range and refetch every environment. + const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + + const days = useMemo( + () => enumerateDays(window.sinceDay, window.untilDay), + [window.sinceDay, window.untilDay], + ); + + // The pull spinner tracks re-scans of environments that have answered + // before. The initial scan renders its own placeholder, and an unreachable + // environment stays pending forever — neither may pin the spinner on. + const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + } + > + ({ value: option.days, label: option.label }))} + selected={windowDays} + onSelect={setWindowDays} + /> + + + + {isPending ? ( + + Scanning provider transcripts… + + ) : environments.length === 0 ? ( + + Connect an environment to see usage. + + ) : ( + <> + + + + + + )} + + + ); +} + +function SegmentedControl(props: { + readonly options: readonly { readonly value: Value; readonly label: string }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; +}) { + return ( + + {props.options.map((option) => { + const active = option.value === props.selected; + return ( + props.onSelect(option.value)} + className={ + active + ? "flex-1 items-center rounded-full bg-subtle-strong py-2" + : "flex-1 items-center py-2" + } + > + + {option.label} + + + ); + })} + + ); +} + +/** Headline figure, the animated daily chart, and its legend, in one card. */ +function ChartCard(props: { + readonly merged: MergedUsage; + readonly days: readonly string[]; + readonly metric: UsageChartMetric; + readonly onMetricChange: (metric: UsageChartMetric) => void; + readonly sinceDay: string; + readonly untilDay: string; +}) { + const { merged, metric } = props; + const colors = useProviderColors(); + const hasActivity = merged.daily.some((day) => day.totalTokens > 0); + + return ( + + + + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Across ${formatCount(merged.sessions)} sessions`} + + + + + + {hasActivity ? ( + + ) : ( + + No activity in this window. + + )} + + + {formatDayShort(props.sinceDay)} + + {merged.providers.map((provider) => ( + + + + {PROVIDER_LABEL[provider.provider]} + + + ))} + + {formatDayShort(props.untilDay)} + + + ); +} + +function MetricToggle(props: { + readonly metric: UsageChartMetric; + readonly onChange: (metric: UsageChartMetric) => void; +}) { + return ( + + {(["cost", "tokens"] as const).map((option) => { + const active = option === props.metric; + return ( + props.onChange(option)} + className={active ? "rounded-full bg-subtle-strong px-3 py-1.5" : "px-3 py-1.5"} + > + + {option} + + + ); + })} + + ); +} + +function ProviderSection(props: { + readonly merged: MergedUsage; + readonly metric: UsageChartMetric; +}) { + const { merged, metric } = props; + const colors = useProviderColors(); + if (merged.providers.length === 0) return null; + + // Ranked by whatever the toggle is showing, so the rows always descend. + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. + const ordered = [...merged.providers].sort((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ); + + return ( + + {ordered.map((provider, index) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( + + + + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + + + + + + + + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + + + ); + })} + + ); +} + +function TotalsSection(props: { readonly merged: MergedUsage }) { + const { merged } = props; + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; + const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + + return ( + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` + : "vs full input rates" + } + /> + + + + + + + ); +} + +function MetricCell(props: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { + return ( + + {props.label} + {props.value} + {props.detail} + + ); +} + +function ModelsSection(props: { readonly merged: MergedUsage }) { + const { merged } = props; + const colors = useProviderColors(); + if (merged.models.length === 0) return null; + + return ( + + {merged.models.map((model, index) => ( + + + + + {model.model} + + + {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + + + {formatUsd(model.costUsd)} + + ))} + + ); +} + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice(props: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly merged: MergedUsage; + readonly isPartial: boolean; +}) { + const failed = props.environments.filter((environment) => environment.error !== null); + const stale = props.environments.filter((environment) => + props.merged.staleEnvironments.includes(environment.environmentId), + ); + const duplicateSources = props.merged.duplicateSources; + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + !props.isPartial + ) { + return null; + } + + return ( + + {props.isPartial ? ( + + Some environments are still reporting. Totals are partial. + + ) : null} + {failed.map((environment) => ( + + {environment.label} could not report usage. + + ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/usage/usageChartData.ts b/apps/mobile/src/features/usage/usageChartData.ts new file mode 100644 index 00000000000..b7b996440cc --- /dev/null +++ b/apps/mobile/src/features/usage/usageChartData.ts @@ -0,0 +1,41 @@ +/** + * Shapes merged daily totals into the per-day provider stacks both chart + * implementations (Swift Charts on iOS, plain views elsewhere) render. + * + * @module usageChartData + */ +import type { UsageProviderKind } from "@t3tools/contracts"; +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { PROVIDER_ORDER } from "./usageProviders"; + +export type UsageChartMetric = "cost" | "tokens"; + +export interface UsageChartDay { + readonly day: string; + /** In {@link PROVIDER_ORDER}, i.e. bottom of the stack first. */ + readonly values: readonly { readonly provider: UsageProviderKind; readonly value: number }[]; + readonly total: number; +} + +/** One entry per day in the window, zero-filled where nothing happened. */ +export function buildChartDays( + days: readonly string[], + daily: readonly DailyTotals[], + metric: UsageChartMetric, +): readonly UsageChartDay[] { + const byDay = new Map(daily.map((totals) => [totals.day, totals])); + return days.map((day) => { + const totals = byDay.get(day); + const values = PROVIDER_ORDER.map((provider) => { + const entry = totals?.byProvider.get(provider); + const value = entry === undefined ? 0 : metric === "cost" ? entry.costUsd : entry.totalTokens; + return { provider, value }; + }); + return { + day, + values, + total: values.reduce((sum, entry) => sum + entry.value, 0), + }; + }); +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts new file mode 100644 index 00000000000..3e2d027a9e3 --- /dev/null +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -0,0 +1,25 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { useColorScheme } from "react-native"; + +/** + * Series and table order. The chart stacks providers from the bottom in this + * order, so it also fixes which band sits on top of the bars. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** + * Claude's brand orange holds in both themes; Codex is neutral and must flip + * with the theme or its bars vanish against the matching background. + */ +export function useProviderColors(): Record { + const scheme = useColorScheme(); + return { + claude: "#d97757", + codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + }; +} diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts new file mode 100644 index 00000000000..91cefda07f5 --- /dev/null +++ b/apps/mobile/src/state/usage.ts @@ -0,0 +1,129 @@ +/** + * Multi-environment usage state. + * + * Every connected environment answers the same typed query; the client merges + * the results. Raw transcripts never leave the machine that produced them. + * + * Mirror of `apps/web/src/state/usage.ts` over mobile's atom wiring; the merge + * rules themselves live in `@t3tools/shared/usageMerge`. + * + * @module state/usage + */ +import { useAtomValue } from "@effect/atom-react"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Reads every environment's summary for one window. + * + * Keyed by the serialised window so switching ranges does not thrash the atom + * cache, and so each environment's query is shared with any other reader of the + * same window. + */ +const usageByWindowAtom = Atom.family((windowKey: string) => + Atom.make((get): readonly EnvironmentUsageStatus[] => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageSummary({ environmentId, input })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary: Option.getOrNull(AsyncResult.value(result)), + }); + } + return statuses; + }).pipe(Atom.withLabel(`mobile-usage:window:${windowKey}`)), +); + +export interface UsageView { + readonly merged: MergedUsage; + readonly environments: readonly EnvironmentUsageStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** + * True while environments that have not failed are still answering. Failed + * environments are reported through their own error rows: totals will not + * improve by waiting on them, so they must not read as "still reporting". + */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useUsage(input: UsageSummaryInput): UsageView { + const windowKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + }), + [input.sinceDay, input.untilDay, input.timeZone], + ); + const atom = usageByWindowAtom(windowKey); + const environments = useAtomValue(atom); + + // Refreshing only the derived atom would re-read the per-environment SWR + // queries within their stale window and change nothing. Refresh each + // environment's query so pull-to-refresh always rescans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + }, [environments, windowKey]); + + const merged = useMemo(() => { + const answered: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; + + return { + merged, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index c57d289f992..6089d22d9aa 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -23,6 +23,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import * as ServerConfig from "../src/config.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; @@ -93,6 +94,7 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer( + databasePath: string, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); + +/** A migrated source db with one thread per lifecycle state. Only + * `stopped-thread` qualifies for the clone. */ +const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( + baseDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase( + databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + // The real shared db carries this column from a branch build without a + // matching migration; reproduce that drift so the filter is exercised. + yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; + + yield* sql`INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) + VALUES + ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL), + ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`; + + const threads = [ + ["stopped-thread", "project-kept", "stopped", null, null], + ["running-thread", "project-kept", "running", null, null], + ["settled-thread", "project-kept", "stopped", "2026-08-01", null], + ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'], + ["deleted-project-thread", "project-deleted", "stopped", null, null], + ] as const; + for (const [threadId, projectId, status, settledAt, monitorJson] of threads) { + yield* sql`INSERT INTO projection_threads + (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json) + VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at) + VALUES (${threadId}, ${status}, '2026-08-01')`; + yield* sql`INSERT INTO orchestration_events + (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) + VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; + } + yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) + VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; + }), + ); + return databasePath; +}); + +it.layer(NodeServices.layer)("migrate-dev-db", (it) => { + it.effect("keeps only stopped threads from live projects and clears auth state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-dest-" }); + const source = yield* createFixtureSource(sourceDir); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + + assert.equal(result.databasePath, path.join(destDir, "userdata", "state.sqlite")); + const kept = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const threads = yield* sql<{ thread_id: string }>` + SELECT thread_id FROM projection_threads ORDER BY thread_id`; + const events = yield* sql<{ stream_id: string }>` + SELECT stream_id FROM orchestration_events`; + const [auth] = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM auth_sessions`; + return { threads, events, authCount: auth?.count ?? 0 }; + }), + ); + assert.deepStrictEqual( + kept.threads.map((row) => row.thread_id), + ["stopped-thread"], + ); + assert.deepStrictEqual( + kept.events.map((row) => row.stream_id), + ["stopped-thread"], + ); + assert.equal(kept.authCount, 0); + }), + ); + + it.effect("fails loudly on a migration slot collision", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-dest-" }); + const source = yield* createFixtureSource(sourceDir); + // Simulate another branch having claimed slot 1 first: the id is + // recorded, so this checkout's migration 1 silently never runs. + yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`UPDATE effect_sql_migrations + SET name = 'SomebodyElsesMigration' WHERE migration_id = 1`; + }), + ); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSlotCollisionError"); + if (error._tag === "MigrateDevDbSlotCollisionError") { + assert.equal(error.slot, 1); + assert.equal(error.appliedName, "SomebodyElsesMigration"); + } + }), + ); + + it.effect("refuses while a dev server holds the destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-dest-" }); + const source = yield* createFixtureSource(sourceDir); + // This test process stands in for a live dev server. + const stateDir = path.join(destDir, "userdata"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, "server-runtime.json"), + `{"version":1,"pid":${process.pid}}`, + ); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbServerRunningError"); + if (error._tag === "MigrateDevDbServerRunningError") { + assert.equal(error.pid, process.pid); + } + }), + ); + + it.effect("refuses a source that resolves to a destination path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-dest-" }); + // A leftover snapshot from a prior failed run, passed as --source: it + // must not be deleted before it is read. + const leftoverSnapshot = path.join(destDir, "userdata", "state.sqlite.migrate-dev-db-tmp"); + yield* fs.makeDirectory(path.dirname(leftoverSnapshot), { recursive: true }); + yield* fs.writeFileString(leftoverSnapshot, "not a real db"); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source: leftoverSnapshot, projects: 5, threadsPerProject: 10 }, + { sharedHome: sharedDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSourceIsDestinationError"); + assert.equal(yield* fs.exists(leftoverSnapshot), true); + }), + ); + + it.effect("refuses to rebuild the shared home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-shared-" }); + const source = yield* createFixtureSource(sourceDir); + + const error = yield* runMigrateDevDb( + { baseDir: sourceDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSharedHomeError"); + }), + ); +}); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts new file mode 100644 index 00000000000..0958f2149f4 --- /dev/null +++ b/apps/server/scripts/migrate-dev-db.ts @@ -0,0 +1,560 @@ +#!/usr/bin/env node + +/** + * Rebuild an isolated dev database from a pruned snapshot of the real + * ~/.t3 database, then run this checkout's migrations against it. + * + * `vp run migrate-dev-db` from a worktree: + * 1. Nukes `/.t3/userdata/state.sqlite`. + * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the + * most recently updated projects and, per project, the most recent + * threads that have fully stopped. Working, settled, and monitored + * threads are skipped so the dev server never adopts live work. + * Auth sessions, pairing links, command receipts, and provider + * runtime rows are dropped — pair a fresh browser against dev. + * 3. Runs migrations on the result. Because the clone carries the real + * `effect_sql_migrations` table, this proves a new migration applies + * on top of the real applied set, and the slot check below catches + * the silent failure where two branches claim the same + * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped). + * + * The event log (`orchestration_events`) is pruned per stream while + * `sqlite_sequence` and `projection_state` carry over untouched, so new + * events keep appending after the old high-water mark and projection + * cursors never rewind. + */ + +// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { Command, Flag } from "effect/unstable/cli"; + +import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; +import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; + +export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( + "MigrateDevDbNotInWorktreeError", + {}, +) { + override get message(): string { + return "Not inside a linked git worktree. Pass --base-dir to target an isolated .t3 directory."; + } +} + +export class MigrateDevDbSharedHomeError extends Schema.TaggedErrorClass()( + "MigrateDevDbSharedHomeError", + {}, +) { + override get message(): string { + return "Refusing to rebuild the shared ~/.t3 database. Use an isolated --base-dir."; + } +} + +export class MigrateDevDbSourceMissingError extends Schema.TaggedErrorClass()( + "MigrateDevDbSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Source database does not exist at '${this.sourcePath}'.`; + } +} + +export class MigrateDevDbSourceIsDestinationError extends Schema.TaggedErrorClass()( + "MigrateDevDbSourceIsDestinationError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Source database '${this.sourcePath}' resolves to a path this command rewrites. Pick a different --source or --base-dir.`; + } +} + +export class MigrateDevDbServerRunningError extends Schema.TaggedErrorClass()( + "MigrateDevDbServerRunningError", + { + databasePath: Schema.String, + pid: Schema.Number, + }, +) { + override get message(): string { + return `Dev database at '${this.databasePath}' is open by a running server (pid ${this.pid} per server-runtime.json). Stop that server first; if that pid is not actually a T3 server (stale descriptor, reused pid), delete the server-runtime.json next to the database and retry.`; + } +} + +export class MigrateDevDbDestinationBusyError extends Schema.TaggedErrorClass()( + "MigrateDevDbDestinationBusyError", + { + databasePath: Schema.String, + reason: Schema.Literals(["write-locked", "wal-held"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const detail = + this.reason === "write-locked" + ? "the database is write-locked" + : "another connection is holding its WAL"; + return `Dev database at '${this.databasePath}' looks in use (${detail}). Stop the dev server first; if none is running, delete the -wal/-shm files next to it.`; + } +} + +/** + * Two branches claimed the same Migrations/NNN_ slot: the id was already + * recorded under a different name, so this checkout's migration was + * silently skipped and its schema changes never applied. + */ +export class MigrateDevDbSlotCollisionError extends Schema.TaggedErrorClass()( + "MigrateDevDbSlotCollisionError", + { + slot: Schema.Number, + codeName: Schema.String, + appliedName: Schema.String, + }, +) { + override get message(): string { + return `Migration slot collision at ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`; + } +} + +export class MigrateDevDbPhaseError extends Schema.TaggedErrorClass()( + "MigrateDevDbPhaseError", + { + phase: Schema.Literals(["snapshot", "prune", "compact", "migrate", "verify"]), + databasePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `migrate-dev-db failed during ${this.phase} on '${this.databasePath}'.`; + } +} + +export interface RunMigrateDevDbInput { + /** Isolated .t3 directory. Defaults to `/.t3` of the cwd. */ + readonly baseDir?: string | undefined; + /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */ + readonly source?: string | undefined; + readonly projects: number; + readonly threadsPerProject: number; +} + +export interface RunMigrateDevDbOptions { + /** Overridable for tests; the directory writes must never target. */ + readonly sharedHome?: string | undefined; +} + +interface KeptProject { + readonly title: string; + readonly threads: number; +} + +const removeDatabaseFiles = Effect.fn("removeDatabaseFiles")(function* (databasePath: string) { + const fs = yield* FileSystem.FileSystem; + for (const suffix of ["", "-wal", "-shm"]) { + yield* fs.remove(`${databasePath}${suffix}`).pipe(Effect.orElseSucceed(() => undefined)); + } +}); + +/** The slice of server-runtime.json this script cares about. */ +const ServerRuntimeState = Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })); +const decodeServerRuntimeState = Schema.decodeEffect(ServerRuntimeState); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to someone else. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +}; + +/** Liveness probe for a running dev server. The server writes its pid to + * server-runtime.json next to the database, which also catches an idle + * server holding an open-but-inactive connection. The SQL probes below back + * that up: BEGIN IMMEDIATE fails while a writer is active, and + * wal_checkpoint(TRUNCATE) reports busy while another connection holds the + * WAL. A leftover -shm alone is not a signal — read-only connections cannot + * clean it up on close. */ +const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const runtimeStatePath = path.join(path.dirname(databasePath), "server-runtime.json"); + const runtimeState = yield* fs.readFileString(runtimeStatePath).pipe( + Effect.flatMap(decodeServerRuntimeState), + // A missing or malformed descriptor is not a liveness signal. + Effect.option, + ); + if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) { + return yield* new MigrateDevDbServerRunningError({ + databasePath, + pid: runtimeState.value.pid, + }); + } + + if (!(yield* fs.exists(databasePath))) { + return; + } + const checkpoint = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("PRAGMA busy_timeout = 0").unprepared; + yield* sql.unsafe("BEGIN IMMEDIATE").unprepared; + yield* sql.unsafe("ROLLBACK").unprepared; + return yield* sql.unsafe<{ busy: number }>("PRAGMA wal_checkpoint(TRUNCATE)").unprepared; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: databasePath })), + Effect.mapError( + (cause) => + new MigrateDevDbDestinationBusyError({ + databasePath, + reason: "write-locked", + cause, + }), + ), + ); + if (checkpoint[0] !== undefined && Number(checkpoint[0].busy) !== 0) { + return yield* new MigrateDevDbDestinationBusyError({ + databasePath, + reason: "wal-held", + }); + } +}); + +const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { + const sql = yield* SqlClient.SqlClient; + + // The shared db can carry monitor_json from a branch build even though no + // migration in this checkout creates it, so filter it only when present. + const threadColumns = yield* sql<{ name: string }>` + SELECT name FROM pragma_table_info('projection_threads')`; + const monitorFilter = threadColumns.some((column) => column.name === "monitor_json") + ? "AND t.monitor_json IS NULL" + : ""; + + // "Stopped" is the persisted subset of the UI's thread status: the session + // reached status 'stopped' and nothing marks the thread settled or + // monitored. The in-memory working/monitoring liveness never persists, so + // filtering the session status is sufficient. + yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS + SELECT t.thread_id, t.project_id, t.updated_at + FROM projection_threads t + JOIN projection_thread_sessions s ON s.thread_id = t.thread_id + WHERE t.deleted_at IS NULL + AND t.archived_at IS NULL + AND t.settled_at IS NULL + AND (t.settled_override IS NULL OR t.settled_override <> 'settled') + ${monitorFilter} + AND s.status = 'stopped'`).unprepared; + + // Projects with clonable threads outrank empty-but-recent ones: the point + // of the exercise is thread data, not the project list. + yield* sql`CREATE TEMP TABLE kept_projects AS + SELECT p.project_id + FROM projection_projects p + LEFT JOIN ( + SELECT project_id, MAX(updated_at) AS last_stopped_at + FROM stopped_threads + GROUP BY project_id + ) q ON q.project_id = p.project_id + WHERE p.deleted_at IS NULL + ORDER BY (q.last_stopped_at IS NULL) ASC, + COALESCE(q.last_stopped_at, p.updated_at) DESC + LIMIT ${input.projects}`; + + yield* sql`CREATE TEMP TABLE kept_threads AS + SELECT thread_id FROM ( + SELECT + st.thread_id, + ROW_NUMBER() OVER ( + PARTITION BY st.project_id + ORDER BY st.updated_at DESC + ) AS recency_rank + FROM stopped_threads st + JOIN kept_projects kp ON kp.project_id = st.project_id + ) + WHERE recency_rank <= ${input.threadsPerProject}`; + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`DELETE FROM projection_projects + WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; + yield* sql`DELETE FROM projection_threads + WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`; + for (const table of [ + "projection_thread_messages", + "projection_thread_activities", + "projection_thread_sessions", + "projection_turns", + "projection_pending_approvals", + "projection_thread_proposed_plans", + "checkpoint_diff_blobs", + ]) { + yield* sql.unsafe( + `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, + ).unprepared; + } + yield* sql`DELETE FROM orchestration_events + WHERE (aggregate_kind = 'thread' + AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) + OR (aggregate_kind = 'project' + AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; + yield* sql`DELETE FROM orchestration_command_receipts`; + yield* sql`DELETE FROM provider_session_runtime`; + yield* sql`DELETE FROM auth_sessions`; + yield* sql`DELETE FROM auth_pairing_links`; + }), + ); + + const keptProjects = yield* sql<{ title: string; threads: number }>` + SELECT + p.title, + (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads + FROM projection_projects p + ORDER BY p.updated_at DESC`; + const [events] = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM orchestration_events`; + + return { + projects: keptProjects as ReadonlyArray, + eventCount: events?.count ?? 0, + }; +}); + +/** Compare this checkout's migration registry against what the cloned + * database recorded: same slot under a different name means the migration + * was skipped, not applied. */ +const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () { + const sql = yield* SqlClient.SqlClient; + const applied = yield* sql<{ migration_id: number; name: string }>` + SELECT migration_id, name FROM effect_sql_migrations`; + const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name])); + for (const [slot, codeName] of migrationManifest) { + const appliedName = appliedById.get(slot); + if (appliedName !== undefined && appliedName !== codeName) { + return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName }); + } + } +}); + +export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( + input: RunMigrateDevDbInput, + options: RunMigrateDevDbOptions = {}, +) { + // SQLite treats a negative LIMIT as "no limit", which would clone + // everything. The CLI flags validate this too; this covers direct callers. + if (input.projects < 1 || input.threadsPerProject < 0) { + return yield* Effect.die("projects must be >= 1 and threadsPerProject >= 0"); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); + const sourcePath = path.resolve( + input.source ?? path.join(sharedHome, "userdata", "state.sqlite"), + ); + + const baseDir = + input.baseDir !== undefined + ? path.resolve(input.baseDir) + : yield* resolveWorktreeT3Home(process.cwd()); + if (baseDir === undefined) { + return yield* new MigrateDevDbNotInWorktreeError(); + } + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + const snapshotPath = `${databasePath}.migrate-dev-db-tmp`; + + if (!(yield* fs.exists(sourcePath))) { + return yield* new MigrateDevDbSourceMissingError({ sourcePath }); + } + const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([ + fs.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)), + fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)), + ]); + if (canonicalBaseDir === canonicalSharedHome) { + return yield* new MigrateDevDbSharedHomeError(); + } + // The destination db and snapshot both get deleted below; a --source that + // resolves to either (e.g. a leftover snapshot file) would be destroyed + // before it is ever read. + const canonicalSourcePath = yield* fs + .realPath(sourcePath) + .pipe(Effect.orElseSucceed(() => sourcePath)); + for (const destination of [databasePath, snapshotPath]) { + const canonicalDestination = yield* fs + .realPath(destination) + .pipe(Effect.orElseSucceed(() => destination)); + if (canonicalSourcePath === canonicalDestination) { + return yield* new MigrateDevDbSourceIsDestinationError({ sourcePath }); + } + } + + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* ensureNotInUse(databasePath); + + const wrapPhase = + (phase: MigrateDevDbPhaseError["phase"], phaseDatabasePath: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => new MigrateDevDbPhaseError({ phase, databasePath: phaseDatabasePath, cause }), + ), + ); + + yield* removeDatabaseFiles(snapshotPath); + // The snapshot is a full-size copy of the source; make sure it is removed + // even when a phase fails partway through. + const { executedMigrations, pruned } = yield* Effect.gen(function* () { + yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${snapshotPath}`; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + wrapPhase("snapshot", sourcePath), + ); + + // Migrate before pruning: a source older than this checkout would + // otherwise crash the prune queries on columns that don't exist yet. + // Running against the full snapshot also exercises new migrations on the + // same data volume the real database would face. + yield* Console.log("Running migrations on the snapshot..."); + const executed = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // Mirror server boot (persistence/Layers/Sqlite.ts). + yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; + return yield* runMigrations(); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("migrate", snapshotPath), + ); + + // Verify while the snapshot is still the only thing touched: a slot + // collision must abort before the old worktree db gets replaced with a + // schema whose colliding migration was silently skipped. + yield* verifyMigrationSlots().pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new MigrateDevDbPhaseError({ phase: "verify", databasePath: snapshotPath, cause }), + ), + }), + ); + + yield* Console.log( + `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`, + ); + const result = yield* pruneSnapshot(input).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("prune", snapshotPath), + ); + + yield* Console.log(`Compacting into ${databasePath}...`); + // Re-check right before the swap: a dev server started while the + // snapshot was migrating and pruning must not lose its database. + yield* ensureNotInUse(databasePath); + yield* removeDatabaseFiles(databasePath); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${databasePath}`; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("compact", databasePath), + ); + return { executedMigrations: executed, pruned: result }; + }).pipe(Effect.ensuring(removeDatabaseFiles(snapshotPath))); + yield* fs.chmod(databasePath, 0o600); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // WAL does not survive VACUUM INTO; set it so first `vp run dev` finds + // the database exactly as server boot would have left it. + yield* sql.unsafe("PRAGMA journal_mode = WAL").unprepared; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: databasePath })), + wrapPhase("compact", databasePath), + ); + + const size = (yield* fs.stat(databasePath)).size; + return { + databasePath, + sizeBytes: Number(size), + projects: pruned.projects, + eventCount: pruned.eventCount, + executedMigrations: executedMigrations.map(([id, name]) => `${id}_${name}`), + }; +}); + +const formatSize = (bytes: number): string => + bytes >= 1024 * 1024 + ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(0)} KB`; + +export const migrateDevDbCommand = Command.make( + "migrate-dev-db", + { + projects: Flag.integer("projects").pipe( + Flag.withDefault(5), + Flag.withDescription("How many recently updated projects to keep."), + ), + threadsPerProject: Flag.integer("threads-per-project").pipe( + Flag.withDefault(10), + Flag.withDescription("How many recent stopped threads to keep per project."), + ), + baseDir: Flag.string("base-dir").pipe( + Flag.optional, + Flag.withDescription("Isolated .t3 directory. Defaults to the current worktree's .t3."), + ), + source: Flag.string("source").pipe( + Flag.optional, + Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."), + ), + }, + ({ projects, threadsPerProject, baseDir, source }) => + Effect.gen(function* () { + const result = yield* runMigrateDevDb({ + projects, + threadsPerProject, + baseDir: Option.getOrUndefined(baseDir), + source: Option.getOrUndefined(source), + }); + yield* Console.log(""); + yield* Console.log( + `Dev database ready: ${result.databasePath} (${formatSize(result.sizeBytes)})`, + ); + for (const project of result.projects) { + yield* Console.log(` ${project.title}: ${project.threads} threads`); + } + yield* Console.log(` ${result.eventCount} orchestration events kept`); + yield* Console.log( + result.executedMigrations.length === 0 + ? " Migrations: already current (no new migrations in this checkout)" + : ` Migrations applied: ${result.executedMigrations.join(", ")}`, + ); + }), +).pipe( + Command.withDescription( + "Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.", + ), +); + +if (import.meta.main) { + Command.run(migrateDevDbCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 568dc3739c0..0a1972c2827 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -225,6 +225,7 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.sourcePath).toBe("favicon.svg"); expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); expect( yield* issueAssetUrl({ @@ -253,6 +254,7 @@ describe("AssetAccess", () => { resource: { _tag: "project-favicon", cwd: root }, }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); + expect(fallbackResult.sourcePath).toBeUndefined(); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -264,6 +266,86 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues project favicon capabilities for a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-override-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), ""); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "auto"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "brand/custom.svg", + }); + + expect(result.sourcePath).toBe("brand/custom.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("ignores a client favicon path hint", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-hint-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "hint.svg"), "hint"); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" }, + projectFaviconPath: "brand/saved.svg", + }); + + expect(result.sourcePath).toBe("brand/saved.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps automatic favicon resolution separate from a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-automatic-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "automatic"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + + expect(result.sourcePath).toBe("favicon.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a resolved project favicon with a non-image extension", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-type-", + }); + yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image"); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "secret.txt", + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("buckets project favicon expiry after content hashing", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c00f7f1a5e3..7157513b14d 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -169,6 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly projectFaviconPath?: string; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -176,6 +177,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; + let sourcePath: string | undefined; switch (input.resource._tag) { case "workspace-file": { @@ -287,16 +289,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); + const faviconPath = yield* faviconResolver + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) + .pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + sourcePath = relativePath ?? undefined; const canonicalFaviconPath = relativePath ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( Effect.mapError( @@ -379,6 +387,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i return { relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, + ...(sourcePath !== undefined ? { sourcePath } : {}), }; }); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 12eebbbd223..b86f6b43893 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -35,6 +35,18 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", expect(unit).not.toContain("versions/1.2.3"); }); +it("survives the kernel OOM-killing a greedy agent child", () => { + const unit = BootService.renderBootServiceUnit({ + nodePath: "/usr/bin/node", + launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + expect(unit).toContain("OOMPolicy=continue"); +}); + const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index b348d2b80ac..e359be42188 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -67,6 +67,11 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", + // Agent tool calls run as children of the server, so they share this cgroup. + // With the systemd default of OOMPolicy=stop, the kernel killing one greedy + // child stops the whole unit: the server, every live agent, and the user's + // connection. Keep running and let Restart=always cover the main process. + "OOMPolicy=continue", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 8e9b7d768db..4369a560d8e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2847,15 +2847,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, + faviconPath: "brand/icon.svg", }); const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModelSelection: string; + readonly faviconPath: string | null; }>` SELECT scripts_json AS "scriptsJson", - default_model_selection_json AS "defaultModelSelection" + default_model_selection_json AS "defaultModelSelection", + favicon_path AS "faviconPath" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -2864,6 +2867,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', + faviconPath: "brand/icon.svg", }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 30eda0c6d4f..2e9e6ff0816 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -502,6 +502,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, + defaultThreadEnvMode: null, + faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -525,6 +527,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } + : {}), + ...(event.payload.faviconPath !== undefined + ? { faviconPath: event.payload.faviconPath } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c89124751b5..be596b36b85 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -275,6 +275,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -284,6 +285,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -393,6 +395,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -402,6 +405,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e744574a73c..3e77f9cf875 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -316,6 +316,8 @@ function mapProjectShellRow( workspaceRoot: row.workspaceRoot, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -391,6 +393,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -844,6 +848,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -866,6 +872,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1542,6 +1550,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1671,6 +1681,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2162,6 +2174,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: option.value.workspaceRoot, repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, + defaultThreadEnvMode: option.value.defaultThreadEnvMode, + faviconPath: option.value.faviconPath ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index f0233a39d88..258aa010e3e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, + type OrchestrationCommand, ProjectId, ProviderItemId, type ServerSettings, @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); + const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-provider-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), + await dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-provider-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + await dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + await dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + status: "ready", + providerName: "codex", runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: createdAt, - lastError: null, - }, - createdAt, - }), - ); + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }); provider.setSession({ provider: ProviderDriverKind.make("codex"), status: "ready", @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { return { engine, + dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, @@ -851,23 +848,21 @@ describe("ProviderRuntimeIngestion", () => { // turn tracked yet. This is the window the Claude resume handshake's // phantom (turn.completed with no turnId) used to slip through, stomping // "starting" back to "ready" for a turn that never existed. - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed-untargeted-completion"), + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-untargeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "starting", - providerName: "claudeAgent", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: seededAt, - lastError: null, - }, - createdAt: seededAt, - }), - ); + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); harness.emit({ type: "turn.completed", @@ -892,23 +887,21 @@ describe("ProviderRuntimeIngestion", () => { // A completion that names its turn still lands even when no active turn // is tracked (e.g. its turn.started was lost). Only untargeted // completions are rejected. - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed-targeted-completion"), + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-targeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "starting", - providerName: "claudeAgent", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: seededAt, - lastError: null, - }, - createdAt: seededAt, - }), - ); + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); harness.emit({ type: "turn.completed", diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a0c06840733..bf5c509fa16 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,47 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("propagates a project favicon path in project.meta.update", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const readModel = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: asEventId("evt-project-create-favicon"), + aggregateKind: "project", + aggregateId: asProjectId("project-favicon"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-favicon"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-favicon"), + metadata: {}, + payload: { + projectId: asProjectId("project-favicon"), + title: "Favicon", + workspaceRoot: "/tmp/favicon", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-favicon"), + projectId: asProjectId("project-favicon"), + faviconPath: "brand/icon.svg", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg"); + }), + ); + it.effect("rejects project.create for an active workspace root that already exists", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts new file mode 100644 index 00000000000..afee13343ad --- /dev/null +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -0,0 +1,103 @@ +import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const projectId = ProjectId.make("project-env-mode"); + +const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ + sequence, + eventId: EventId.make(`evt-project-env-mode-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: now, + commandId: CommandId.make(`cmd-project-env-mode-${sequence}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-project-env-mode-${sequence}`), + metadata: {}, + payload: { + projectId, + title: "Env mode", + workspaceRoot: "/tmp/env-mode", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, +}); + +it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { + it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + expect(readModel.projects[0]?.defaultThreadEnvMode).toBeNull(); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { defaultThreadEnvMode?: unknown }).defaultThreadEnvMode).toBe( + "worktree", + ); + + const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); + expect(updated.projects[0]?.defaultThreadEnvMode).toBe("worktree"); + }), + ); + + it.effect("omits the field when unset and clears it on explicit null", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + + const unrelated = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-title"), + projectId, + title: "Renamed", + }, + readModel, + }); + const unrelatedEvent = Array.isArray(unrelated) ? unrelated[0] : unrelated; + expect("defaultThreadEnvMode" in (unrelatedEvent.payload as object)).toBe(false); + + const set = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + const setEvent = Array.isArray(set) ? set[0] : set; + const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 }); + + const clear = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-clear"), + projectId, + defaultThreadEnvMode: null, + }, + readModel: afterSet, + }); + const clearEvent = Array.isArray(clear) ? clear[0] : clear; + const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 }); + expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 73f1cbf9127..e057764683e 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -518,4 +518,55 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); + + it.effect("drops an onlyIfSettled session stop when the thread was re-engaged", () => + Effect.gen(function* () { + const stopCommand = (commandId: string) => + ({ + type: "thread.session.stop", + commandId: CommandId.make(commandId), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + onlyIfSettled: true, + }) as const; + + // Still settled with an idle session: the cleanup stop goes through. + const stopped = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-settled-idle"), + readModel: makeReadModel("settled", null, makeSession("ready")), + }); + const stoppedEvents = Array.isArray(stopped) ? stopped : [stopped]; + expect(stoppedEvents.map((event) => event.type)).toEqual(["thread.session-stop-requested"]); + + // Re-engaged before the stop was decided (a turn start unsettles the + // thread): the stale cleanup stop must not kill the new session. + const unsettledError = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-unsettled"), + readModel: makeReadModel(null, null, makeSession("starting")), + }).pipe(Effect.flip); + expect(unsettledError._tag).toBe("OrchestrationCommandInvariantError"); + + // Still settled but the session is already coming alive: same drop. + const aliveError = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-session-alive"), + readModel: makeReadModel("settled", null, makeSession("starting")), + }).pipe(Effect.flip); + expect(aliveError._tag).toBe("OrchestrationCommandInvariantError"); + + // Without the flag the stop stays unconditional (archive, stop button). + const unconditional = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-unconditional"), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + }, + readModel: makeReadModel(null, null, makeSession("starting")), + }); + const unconditionalEvents = Array.isArray(unconditional) ? unconditional : [unconditional]; + expect(unconditionalEvents.map((event) => event.type)).toEqual([ + "thread.session-stop-requested", + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3de2592c884..a48bb29e154 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,6 +250,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, + faviconPath: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -287,6 +288,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), + ...(command.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: command.defaultThreadEnvMode } + : {}), + ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, @@ -1116,11 +1121,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.stop": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + // Settle-cleanup stops are conditional: between the settle landing and + // this command, another client may have re-engaged the thread (a turn + // start unsettles it and brings the session alive). Commands are + // decided serially against this read model, so checking here — not in + // the dispatcher's pre-settle snapshot — closes that race. + if (command.onlyIfSettled === true) { + const sessionComingAlive = + thread.session?.status === "starting" || thread.session?.status === "running"; + if ( + thread.settledOverride !== "settled" || + sessionComingAlive || + threadHasQueuedTurnStart(thread, command.createdAt) + ) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, + }), + ); + } + } return { ...(yield* withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 5acf3ee6968..f486dcb2bcb 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -214,6 +214,8 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, + defaultThreadEnvMode: null, + faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -246,6 +248,12 @@ export function projectEvent( ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), + ...(payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } + : {}), + ...(payload.faviconPath !== undefined + ? { faviconPath: payload.faviconPath } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e..ba133bb24a4 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -35,6 +35,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, default_model_selection_json, + default_thread_env_mode, + favicon_path, scripts_json, created_at, updated_at, @@ -45,6 +47,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.title}, ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${row.defaultThreadEnvMode}, + ${row.faviconPath ?? null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -55,6 +59,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, + default_thread_env_mode = excluded.default_thread_env_mode, + favicon_path = excluded.favicon_path, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -72,6 +78,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -91,6 +99,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 71d7df566fd..bebd8fbb4a7 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + defaultThreadEnvMode: null, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 733c52fab3e..b137cedfbed 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -51,6 +51,8 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; +import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; +import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; /** * Migration loader with all migrations defined inline. @@ -101,6 +103,8 @@ export const migrationEntries = [ [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], [38, "ProjectionThreadsPinOrderKey", Migration0038], + [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], + [40, "ProjectionProjectFaviconPath", Migration0040], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts new file mode 100644 index 00000000000..2ac6f78e6de --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "default_thread_env_mode")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_thread_env_mode TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts new file mode 100644 index 00000000000..7fd43d9b2ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("040_ProjectionProjectFaviconPath", (it) => { + it.effect("adds the nullable favicon path to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 39 }); + yield* runMigrations({ toMigrationInclusive: 40 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const faviconPath = columns.find((column) => column.name === "favicon_path"); + + assert.equal(faviconPath?.name, "favicon_path"); + assert.equal(faviconPath?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts new file mode 100644 index 00000000000..8424e8d5e72 --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "favicon_path")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN favicon_path TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a269..339439fdfcb 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,13 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { + IsoDateTime, + ModelSelection, + ProjectId, + ProjectScript, + ThreadEnvMode, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -19,6 +25,8 @@ export const ProjectionProject = Schema.Struct({ title: Schema.String, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + faviconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a5..34ad74da621 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -77,6 +77,33 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon override", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "brand/custom.svg", "custom"); + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand/custom.svg"); + }), + ); + + it.effect("falls back when a saved override is missing from a checkout", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630..0b447bd4f3e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -91,6 +91,7 @@ export class ProjectFaviconResolver extends Context.Service< */ readonly resolvePath: ( cwd: string, + faviconPath?: string, ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -168,7 +169,7 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd) { + )(function* (cwd, faviconPath) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -179,6 +180,15 @@ export const make = Effect.gen(function* () { }), ), ); + // A grouped project's saved path can be absent from one checkout. Use it + // where it exists and retain automatic discovery for the other checkouts. + if (faviconPath !== undefined) { + const existing = yield* findExistingFile(projectCwd, [faviconPath]); + if (existing) { + return existing; + } + } + // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 591d1408f36..811f202125b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4157,6 +4157,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(ultracode ? { ultracode: true } : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + // The attachments dir grant lets the agent Read/copy pasted images at + // the paths ProviderService injects into the turn text, without an + // approval prompt. It is a leaf directory holding only attachment + // files; siblings like secrets/ and state.sqlite stay ungranted. + const additionalDirectories = [ + ...(input.cwd ? [input.cwd] : []), + serverConfig.attachmentsDir, + ]; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -4180,7 +4188,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( includePartialMessages: true, canUseTool, env: claudeEnvironment, - ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -4215,7 +4223,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": additionalDirectories, "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 3a02c45b23f..38e0e0a7b2c 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -245,4 +245,53 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + turnIds: [activeTurnId, queuedTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-queued-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.sendTurn({ input: "queued follow-up" }); + yield* runtime.interruptTurn(); + + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); + assert.deepEqual(interrupts.at(-1), { + threadId: ROOT, + turnId: activeTurnId, + }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 57a1162dd08..58c012bd63e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -814,13 +814,13 @@ function currentProviderThreadId(session: ProviderSession): string | undefined { function updateSession( sessionRef: Ref.Ref, - updates: Partial, + updates: Partial | ((session: ProviderSession) => Partial), ): Effect.Effect { return Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* Ref.update(sessionRef, (session) => ({ ...session, - ...updates, + ...(typeof updates === "function" ? updates(session) : updates), updatedAt, })); }); @@ -1782,11 +1782,14 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, { + yield* updateSession(sessionRef, (session) => ({ status: "running", - activeTurnId: turnId, + // Codex accepts follow-ups while the current turn is still + // running. The response contains the queued turn id, but + // turn/interrupt only accepts the id that is active now. + activeTurnId: session.activeTurnId ?? turnId, ...(normalizedModel ? { model: normalizedModel } : {}), - }); + })); const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); return { threadId: options.threadId, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e163b6a9452..263d8ef8d0b 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -55,11 +55,15 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); +const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provide(NodeServices.layer), +); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -292,6 +296,7 @@ function makeProviderServiceLayer() { Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -343,6 +348,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -402,6 +408,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -486,6 +493,7 @@ it.effect( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -556,6 +564,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -611,6 +620,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -656,6 +666,7 @@ it.effect("marks the persisted binding stopped when session.exited flows through Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -736,6 +747,7 @@ it.effect( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -823,6 +835,7 @@ it.effect( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -901,6 +914,7 @@ it.effect( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -981,6 +995,7 @@ it.effect("refreshes the binding lastSeenAt when turn activity flows through the Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1076,6 +1091,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1142,6 +1158,7 @@ it.effect( ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1201,6 +1218,7 @@ it.effect( ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1332,6 +1350,54 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("appends attachment file paths to the turn input text", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-attach"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-attach"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 123, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "use this screenshot", + attachments: [attachment], + }); + + const turnInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(typeof turnInput.input, "string"); + const turnText = turnInput.input ?? ""; + assert.equal(turnText.startsWith("use this screenshot"), true); + assert.include(turnText, '[Attached image "screenshot.png" is saved at: '); + assert.equal(turnText.endsWith(`${attachment.id}.png]`), true); + + // An attachment-only turn stays valid and the injected line becomes the + // whole input text, so the agent still learns the path. + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + attachments: [attachment], + }); + const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + + yield* provider.stopSession({ threadId: session.threadId }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1712,6 +1778,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1750,6 +1817,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1818,6 +1886,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1851,6 +1920,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b6dfe2d397b..489133e1003 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -36,6 +36,8 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; import { increment, providerMetricAttributes, @@ -204,6 +206,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical @@ -802,16 +805,44 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - attachments: parsed.attachments ?? [], - }; - if (!input.input && input.attachments.length === 0) { + const attachments = parsed.attachments ?? []; + if (!parsed.input && attachments.length === 0) { return yield* toValidationError( "ProviderService.sendTurn", "Either input text or at least one attachment is required", ); } + + // Adapters inline attachment pixels into the model prompt, but the model's + // tools cannot dereference pixels. Appending the on-disk path is what lets + // a turn like "include this screenshot in the PR" copy the actual file. + // This runs after schema decode, so the appended lines are exempt from the + // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so + // the overhead is bounded. Unresolvable ids are skipped here and surface + // as adapter errors when the file is read for inlining. + const attachmentPathLines = attachments.flatMap((attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return attachmentPath === null + ? [] + : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; + }); + const inputTextWithAttachmentPaths = + attachmentPathLines.length === 0 + ? parsed.input + : [parsed.input, attachmentPathLines.join("\n")] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + const input = { + ...parsed, + ...(inputTextWithAttachmentPaths !== undefined + ? { input: inputTextWithAttachmentPaths } + : {}), + attachments, + }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 59580d2c7e6..f06e984c9aa 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -16,6 +16,7 @@ const fixture = JSON.parse( const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +let turnStartCount = 0; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -43,14 +44,20 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { - write({ id, result: fixture.responses.turnStart }); + const turnId = script.turnIds?.[turnStartCount]; + const turn = turnId + ? { ...fixture.responses.turnStart.turn, id: turnId } + : fixture.responses.turnStart.turn; + turnStartCount += 1; + write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; - const turn = fixture.responses.turnStart.turn; - write({ - jsonrpc: "2.0", - method: "turn/started", - params: { threadId: rootThreadId, turn }, - }); + if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { + write({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: rootThreadId, turn }, + }); + } for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } @@ -75,6 +82,20 @@ rl.on("line", (line) => { `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, ); + if ( + script.expectedActiveTurnId && + message.params?.threadId === script.rootThreadId && + message.params?.turnId !== script.expectedActiveTurnId + ) { + write({ + id, + error: { + code: -32000, + message: `expected active turn id ${message.params?.turnId} but found ${script.expectedActiveTurnId}`, + }, + }); + return; + } if (script.failInterruptFor && script.failInterruptFor === target) { write({ id, error: { code: -32000, message: "thread already closed" } }); return; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6da46aafb79..0217e6fdab3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7156,6 +7156,126 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("stops the provider session after settle without closing terminals", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); + assert.equal(sessionStopCommand.onlyIfSettled, true); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("settles without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("archives and still closes terminals when session stop fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-stop-failure"); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37..28a30481b1b 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -245,7 +245,7 @@ export const make = Effect.gen(function* () { }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly); }).pipe( Effect.provide( workspaceSearchIndexes.get( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 15572837030..1fdf956447d 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,10 @@ -import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; +import { + FileFinder, + type FileItem, + type GrepCursor, + type GrepOptions, + type GrepResult, +} from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -11,6 +17,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +function fileItem(relativePath: string): FileItem { + return { + relativePath, + fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1), + size: 1, + modified: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + totalFrecencyScore: 0, + gitStatus: "clean", + }; +} + +it.effect("filters image searches before applying the result limit", () => + Effect.scoped( + Effect.gen(function* () { + const items = [ + ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), + fileItem("public/icon.svg"), + ]; + const fileSearch = vi.fn(() => ({ + ok: true as const, + value: { + items, + scores: [], + totalMatched: items.length, + totalFiles: items.length, + }, + })); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + fileSearch, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); + const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); + + expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(fileSearch).toHaveBeenCalledTimes(2); + expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); + }), + ), +); + it.effect("preserves unexpected FileFinder creation failures", () => Effect.gen(function* () { const cause = new Error("native initialization failed"); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8bf36b7a80a..eeb2df342c2 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,6 +23,7 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; @@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service< query: string, limit: number, kind?: ProjectEntryKind, + imageOnly?: boolean, ) => Effect.Effect; readonly searchContents: ( input: Omit, @@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null { return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; } -function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { +function mapFileSearchResult( + result: SearchResult, + limit: number, + imageOnly = false, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toFileEntry(item); + return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; + }); return { - entries: result.items - .flatMap((item) => { - const entry = toFileEntry(item); - return entry ? [entry] : []; - }) - .slice(0, limit), - truncated: result.totalMatched > limit, + entries: entries.slice(0, limit), + truncated: entries.length > limit || result.totalMatched > result.items.length, }; } @@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit, kind) { - const pageSize = Math.max(1, limit + 1); - if (kind === "file") { + )(function* (query, limit, kind, imageOnly) { + const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); + if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - return mapFileSearchResult(result, limit); + return mapFileSearchResult(result, limit, imageOnly); } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index abc08d8c552..14a20e2f66b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1041,53 +1041,83 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - const shouldStopSessionAfterArchive = - normalizedCommand.type === "thread.archive" - ? yield* projectionSnapshotQuery - .getThreadShellById(normalizedCommand.threadId) - .pipe( - Effect.map( - Option.match({ - onNone: () => false, - onSome: (thread) => - thread.session !== null && thread.session.status !== "stopped", - }), - ), - Effect.orElseSucceed(() => false), - ) - : false; + // Archive and settle both mean "done with this thread", so a + // live provider session must not keep running background work + // (PR monitors, dev servers, subagent fleets) after either + // lands. The decider rejects settling a starting/running + // session, so for settle this only ever stops an idle one; a + // stopped session-set does not count as activity, so the stop + // cannot un-settle the thread it follows. + const parkingCommand = + normalizedCommand.type === "thread.archive" || + normalizedCommand.type === "thread.settle" + ? normalizedCommand + : undefined; + // Best-effort on purpose: the user's archive/settle must not + // fail because this cleanup read blipped, so a failed read + // logs and skips the stop instead of propagating. + const shouldStopSessionAfterCommand = parkingCommand + ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catchCause((cause) => + Effect.logWarning( + "failed to read thread session state before session-stop check", + { threadId: parkingCommand.threadId, cause }, + ).pipe(Effect.as(false)), + ), + ) + : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); - if (normalizedCommand.type === "thread.archive") { - if (shouldStopSessionAfterArchive) { + if (parkingCommand) { + const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-archive:${normalizedCommand.commandId}`, + `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, ), - threadId: normalizedCommand.threadId, + threadId: parkingCommand.threadId, createdAt: yield* nowIso, + // A settled thread can be re-engaged before this stop is + // decided; the decider then drops the stop instead of + // killing the new session. Archive stops stay + // unconditional: turn starts on archived threads are + // rejected, so there is no new session to protect. + ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider session during archive", { - threadId: normalizedCommand.threadId, + Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { + threadId: parkingCommand.threadId, cause, }), ), ); } - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); + // Terminals are user-opened panes, not thread background + // work: archive removes the thread from view so they close + // with it, but a settled thread stays reachable and may be + // un-settled, so its terminals stay up. + if (parkingCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: parkingCommand.threadId, + error: error.message, + }), + ), + ); + } } return result; }).pipe( @@ -1795,9 +1825,33 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { - if (input.resource._tag !== "workspace-file") { + if (input.resource._tag === "attachment") { return yield* issueAssetUrl({ resource: input.resource }); } + if (input.resource._tag === "project-favicon") { + const project = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(input.resource.cwd) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + ...(project.value.faviconPath + ? { projectFaviconPath: project.value.faviconPath } + : {}), + }); + } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.resource.threadId) .pipe( diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 701af3a79fc..f8c0b5ae75f 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = | { readonly _tag: "Loading" } | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; + | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; export function useAssetUrlState( environmentId: EnvironmentId, @@ -32,7 +32,13 @@ export function useAssetUrlState( return { _tag: "Loading" }; } const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return url === null + ? { _tag: "Failure" } + : { + _tag: "Success", + url, + ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + }; } export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40864242a3f..8d084204cfe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5974,6 +5974,11 @@ export function ChatViewContent(props: ChatViewProps) { rightPanelAvailable={activeProject !== null} rightPanelOpen={rightPanelOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + // Suppressed while the Agents surface is visible: the roster itself is + // on screen, so the toggle badge would be pointing at nothing. + liveAgentCount={ + rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> @@ -6126,6 +6131,7 @@ export function ChatViewContent(props: ChatViewProps) { changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} + activeProjectFaviconPath={activeProject?.faviconPath ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ @@ -6506,6 +6512,7 @@ export function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -6534,6 +6541,7 @@ export function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 605127f9737..d8e03a74b3c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -150,6 +150,7 @@ function projectFavicon(project: Project) { ); @@ -1505,6 +1506,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + icon: , + run: async () => { + await navigate({ to: "/settings/projects" }); + }, + }); + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 76191e6d4d7..a62f5edd4df 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -76,7 +76,6 @@ import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -312,7 +311,8 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); + const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 2c1f99ffa0f..54fbc12df94 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2253,7 +2253,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index c2fac8beb7e..bbeeda4bc7f 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", + lastResource: null as unknown, })); const hooks = vi.hoisted(() => { @@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => { vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("../assets/assetUrls", () => ({ - useAssetUrl: () => testState.faviconUrl, + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.lastResource = resource; + return { _tag: "Success", url: testState.faviconUrl }; + }, })); import { ProjectFavicon } from "./ProjectFavicon"; @@ -125,4 +129,18 @@ describe("ProjectFavicon", () => { expect(afterDisplayedError[0]).not.toBeNull(); expect(afterDisplayedError[1]).toBeNull(); }); + + it("requests a saved favicon path when one is set", () => { + ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + faviconPath: "brand/icon.svg", + }); + + expect(testState.lastResource).toEqual({ + _tag: "project-favicon", + cwd: "/workspace-test", + path: "brand/icon.svg", + }); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 66216e10cb5..619bbf37001 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -6,7 +6,7 @@ import { import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; -import { useAssetUrl } from "../assets/assetUrls"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); @@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + faviconPath?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAssetUrl(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - }); + const state = useProjectFaviconAsset(input); + const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { @@ -40,6 +39,18 @@ export function ProjectFavicon(input: { ); } +export function useProjectFaviconAsset(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +}) { + return useAssetUrlState(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + ...(input.faviconPath ? { path: input.faviconPath } : {}), + }); +} + function ProjectFaviconFallback({ className, icon: Icon, diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 7f21177e7b1..304922909b0 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,61 +1,28 @@ import type { ProjectScript, - ProjectScriptIcon, ResolvedKeybindingsConfig, T3ProjectFileScript, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - BugIcon, - ChevronDownIcon, - DownloadIcon, - FlaskConicalIcon, - HammerIcon, - ListChecksIcon, - PlayIcon, - PlusIcon, - SettingsIcon, - WrenchIcon, -} from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; -import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { - commandForProjectScript, - nextProjectScriptId, - primaryProjectScript, -} from "~/projectScripts"; +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { Group, GroupSeparator } from "./ui/group"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; import { Menu, MenuGroup, @@ -66,48 +33,9 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Switch } from "./ui/switch"; -import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ - { id: "play", label: "Play" }, - { id: "test", label: "Test" }, - { id: "lint", label: "Lint" }, - { id: "configure", label: "Configure" }, - { id: "build", label: "Build" }, - { id: "debug", label: "Debug" }, -]; - -function ScriptIcon({ - icon, - className = "size-3.5", -}: { - icon: ProjectScriptIcon; - className?: string; -}) { - if (icon === "test") return ; - if (icon === "lint") return ; - if (icon === "configure") return ; - if (icon === "build") return ; - if (icon === "debug") return ; - return ; -} - -export interface NewProjectScriptInput { - name: string; - command: string; - icon: ProjectScriptIcon; - runOnWorktreeCreate: boolean; - keybinding: string | null; - /** Optional URL to open in the in-app preview when this script runs. */ - previewUrl: string | null; - /** When true, automatically open the preview panel pointed at `previewUrl`. */ - autoOpenPreview: boolean; -} - -export type ProjectScriptActionResult = AtomCommandResult; +export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, }); - const [dialogOpen, setDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [validationError, setValidationError] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ ), [fileScripts, scripts], ); - const isEditing = editingScriptId !== null; const dropdownItemClassName = "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromKeyboardEvent(event, navigator.platform); - if (!next) return; - setKeybinding(next); - }; - - const submitAddScript = async (event: FormEvent) => { - event.preventDefault(); - const trimmedName = name.trim(); - const trimmedCommand = command.trim(); - if (trimmedName.length === 0) { - setValidationError("Name is required."); - return; - } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); - return; - } - - setValidationError(null); - let payload: NewProjectScriptInput; - try { - const scriptIdForValidation = - editingScriptId ?? - nextProjectScriptId( - trimmedName, - scripts.map((script) => script.id), - ); - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: commandForProjectScript(scriptIdForValidation), - }); - const trimmedPreviewUrl = previewUrl.trim(); - payload = { - name: trimmedName, - command: trimmedCommand, - icon, - runOnWorktreeCreate, - keybinding: keybindingRule?.key ?? null, - previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, - autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, - } satisfies NewProjectScriptInput; - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - return; - } - - const result = editingScriptId - ? await onUpdateScript(editingScriptId, payload) - : await onAddScript(payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - } - return; - } - setDialogOpen(false); - setIconPickerOpen(false); - }; - const openAddDialog = () => { - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setIconPickerOpen(false); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; const openEditDialog = (script: ProjectScript) => { setActionsMenuOpen({ scripts: false, imports: false }); - setEditingScriptId(script.id); - setName(script.name); - setCommand(script.command); - setIcon(script.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(script.runOnWorktreeCreate); - setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); - setPreviewUrl(script.previewUrl ?? ""); - setAutoOpenPreview(script.autoOpenPreview ?? false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ // Surface the failure through the regular add dialog, prefilled so the // user can adjust and retry. const error = squashAtomCommandFailure(result); - setEditingScriptId(null); - setName(payload.name); - setCommand(payload.command); - setIcon(payload.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(payload.runOnWorktreeCreate); - setKeybinding(""); - setPreviewUrl(payload.previewUrl ?? ""); - setAutoOpenPreview(payload.autoOpenPreview); - setValidationError(error instanceof Error ? error.message : "Failed to import action."); - setDialogOpen(true); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ )} - { - setDialogOpen(open); - if (!open) { - setIconPickerOpen(false); - } - }} - onOpenChangeComplete={(open) => { - if (open) return; - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - }} - open={dialogOpen} - > - - - {isEditing ? "Edit Action" : "Add Action"} - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - -
-
- -
- - - } - > - - - -
- {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
-
-
- setName(event.target.value)} - /> -
-
-
- - -

- Press a shortcut. Use Backspace to clear. -

-
-
- -