diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 16e5c356219a..3a606a692a28 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -28,6 +28,7 @@ jobs: RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + RELAY_TUNNEL_CLEANUP_MODE: ${{ vars.RELAY_TUNNEL_CLEANUP_MODE }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index ce9e0fb48979..1360fb545bd0 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -80,6 +80,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), + ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}), }; if (sourceItem.children) { @@ -168,6 +169,7 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, enabled: !item.disabled, + ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}), }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 509778521511..c9ed4c66bce9 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -14,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; @@ -559,6 +561,55 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("marks the backend stop for an install as an update restart", () => { + let markersAtStop: ReadonlyArray = []; + const harness = makeHarness({ + stopBackend: Effect.sync(() => { + markersAtStop = [...harness.updateRestartMarkers]; + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + assert.isTrue((yield* updates.install).accepted); + assert.deepEqual(markersAtStop, [ + environment.path.join(environment.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE), + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("drops the update restart marker when an install is interrupted", () => + Effect.gen(function* () { + const stopping = yield* Deferred.make(); + const harness = makeHarness({ + stopBackend: Deferred.succeed(stopping, undefined).pipe(Effect.andThen(Effect.never)), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const installFiber = yield* updates.install.pipe(Effect.forkScoped); + yield* Deferred.await(stopping); + assert.equal(harness.updateRestartMarkers.size, 1); + + yield* Fiber.interrupt(installFiber); + assert.equal(harness.updateRestartMarkers.size, 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("keeps windows and restarts backends when quitAndInstall fails", () => { const harness = makeHarness({ quitAndInstall: Effect.fail( @@ -583,6 +634,8 @@ describe("DesktopUpdates", () => { assert.isTrue(result.accepted); assert.isFalse(yield* Ref.get(desktopState.quitting)); assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + // The restarted old backend must release its tunnel on a later quit. + assert.equal(harness.updateRestartMarkers.size, 0); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c35b52e8343d..5336c8ac1d22 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,4 +1,5 @@ import { + DESKTOP_UPDATE_RESTART_MARKER_FILE, DesktopUpdateChannelSchema, type DesktopRuntimeInfo, type DesktopUpdateActionResult, @@ -501,8 +502,35 @@ export const make = Effect.gen(function* () { ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + // Tells the primary backend that the coming stop is an update restart, so it + // keeps its managed tunnel for the backend the updated app starts. Best + // effort: without the marker the backend only re-provisions its tunnel. + const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime"); + const updateRestartMarkerPath = environment.path.join( + updateRestartMarkerDir, + DESKTOP_UPDATE_RESTART_MARKER_FILE, + ); + const writeUpdateRestartMarker = fileSystem + .makeDirectory(updateRestartMarkerDir, { recursive: true }) + .pipe( + Effect.andThen(fileSystem.writeFileString(updateRestartMarkerPath, "")), + Effect.catch((error) => + logUpdaterWarning("Could not write the update restart marker.", { errorTag: error._tag }), + ), + ); + + // A failed or interrupted install brings no updated backend, so a later + // quit must release the tunnel. + const removeUpdateRestartMarker = fileSystem + .remove(updateRestartMarkerPath, { force: true }) + .pipe(Effect.ignore); + const resetInstallAction = Effect.all( - [finishUpdateAction("install"), Ref.set(desktopState.quitting, false)], + [ + finishUpdateAction("install"), + Ref.set(desktopState.quitting, false), + removeUpdateRestartMarker, + ], { discard: true }, ); @@ -517,6 +545,7 @@ export const make = Effect.gen(function* () { if (!ownsRecovery) return; yield* Ref.set(desktopState.quitting, false); + yield* removeUpdateRestartMarker; yield* Effect.gen(function* () { const instances = yield* pool.list; const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, { @@ -586,6 +615,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, true); return yield* Effect.gen(function* () { + yield* writeUpdateRestartMarker; // Stop every backend in the pool, not just the primary. With // parallel WSL + Windows backends, leaving the WSL instance up // means quitAndInstall's app.quit() exits before the pool's diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index fbcbb349f9e7..cc50349e1fe8 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -203,7 +204,23 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; + // Tracks the restart markers installs leave, so installs stay free of real + // disk I/O that would outrun the tests' settle loops. + const updateRestartMarkers = new Set(); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + writeFileString: (path) => + Effect.sync(() => { + updateRestartMarkers.add(path); + }), + remove: (path) => + Effect.sync(() => { + updateRestartMarkers.delete(path); + }), + }); + const layer = DesktopUpdates.layer.pipe( + Layer.provide(fileSystemLayer), Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), @@ -226,6 +243,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount: () => checkCount, quitAndInstalls: () => quitAndInstallCount, installSteps, + updateRestartMarkers, downloadCount: () => downloadCount, feedUrls: () => feedUrls, fullChangelog: () => fullChangelog, diff --git a/apps/marketing/public/harnesses/antigravity.png b/apps/marketing/public/harnesses/antigravity.png deleted file mode 100644 index df1e22dbbd21..000000000000 Binary files a/apps/marketing/public/harnesses/antigravity.png and /dev/null differ diff --git a/apps/marketing/public/harnesses/antigravity.svg b/apps/marketing/public/harnesses/antigravity.svg new file mode 100644 index 000000000000..13e1ec9e9849 --- /dev/null +++ b/apps/marketing/public/harnesses/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/apps/marketing/public/harnesses/opencode-dark.svg b/apps/marketing/public/harnesses/opencode-dark.svg index fc467bf84407..8c5e734ece6c 100644 --- a/apps/marketing/public/harnesses/opencode-dark.svg +++ b/apps/marketing/public/harnesses/opencode-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 669bdce8a72d..15a175e0317e 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -37,7 +37,7 @@ const mobileEndorsementRows = [
-
+
Antigravity
Google sign-in
@@ -709,11 +709,6 @@ const mobileEndorsementRows = [ object-fit: contain; } - /* The Antigravity icon ships with its own rounded dark tile, so it fills the - card edge to edge instead of sitting inside it. */ - .hf-antigravity .hero-float-card { background: #0d0d10; border-color: rgba(255, 255, 255, 0.1); } - .hf-antigravity .hero-float-card img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; } - @keyframes mark-in { from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); } to { opacity: 1; transform: translate(0, 0) rotate(var(--rot)) scale(1); } @@ -829,7 +824,7 @@ const mobileEndorsementRows = [ flex-shrink: 0; width: 28px; height: 28px; display: grid; place-items: center; } - .harness-mark img { width: 22px; height: 22px; object-fit: contain; border-radius: 5px; } + .harness-mark img { width: 22px; height: 22px; object-fit: contain; } .harness-meta { flex: 1; min-width: 0; } .harness-name { diff --git a/apps/mobile/assets/antigravity.png b/apps/mobile/assets/antigravity.png index df1e22dbbd21..ecf863511c66 100644 Binary files a/apps/mobile/assets/antigravity.png and b/apps/mobile/assets/antigravity.png differ diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml index e66f03cffae5..e98c1ad6b312 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt index ef33a5d7c25d..b8a916129ea7 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -8,7 +8,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Bundle +import android.os.Build import android.view.View import android.widget.RemoteViews import org.json.JSONObject @@ -29,15 +29,6 @@ class SubscriptionUsageWidget : AppWidgetProvider() { context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) } - override fun onAppWidgetOptionsChanged( - context: Context, - manager: AppWidgetManager, - id: Int, - options: Bundle - ) { - update(context, manager, id) - } - companion object { const val PREFERENCES = "t3_subscription_widget" private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" @@ -56,59 +47,68 @@ class SubscriptionUsageWidget : AppWidgetProvider() { } private fun update(context: Context, manager: AppWidgetManager, id: Int) { + // The receiver is disabled below 12L (values-v32/bools.xml), but the module still calls in. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) return val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull() - val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) - openAppIntent(context, id, snapshot)?.let { - views.setOnClickPendingIntent(R.id.t3_widget_root, it) - } + val openApp = openAppIntent(context, id, snapshot) val providers = snapshot?.optJSONArray("providers") val now = System.currentTimeMillis() var nextExpiry = Long.MAX_VALUE - var totalRows = 0 val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> val provider = providers?.optJSONObject(index) ?: return@mapNotNull null val windows = provider.optJSONArray("windows") val expiresAt = provider.optLong("expiresAt") if (expiresAt > now && windows != null && windows.length() > 0) { nextExpiry = minOf(nextExpiry, expiresAt) - totalRows += provider.optInt("totalWindows", windows.length()) (0 until windows.length()).map { provider to windows.optJSONObject(it) } } else { - totalRows++ listOf(provider to null) } } - // Show each provider before filling spare space with its other windows. + // Keep the first quota from each provider near the top of the list. val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> groups.mapNotNull { it.getOrNull(index) } } - if (rows.isNotEmpty()) { - views.removeAllViews(R.id.t3_widget_rows) - val options = manager.getAppWidgetOptions(id) - val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) - val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size) - for ((provider, window) in rows.take(count)) { - views.addView(R.id.t3_widget_rows, rowView(context, provider, window)) - } - val remaining = totalRows - count - val checkedAt = snapshot?.optLong("checkedAt") ?: 0 + val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) + // Count limits only; "Open app to refresh" placeholders are not entries. + val limits = rows.count { (_, window) -> window != null } + // Without limits the layout's plain title stays. + if (limits > 0) { + views.setTextViewText( + R.id.t3_widget_title, + context.getString(R.string.t3_subscription_widget_title_count, limits) + ) + views.setContentDescription( + R.id.t3_widget_title, + context.resources.getQuantityString( + R.plurals.t3_subscription_widget_title_description, + limits, + limits + ) + ) + } + openApp?.let { views.setOnClickPendingIntent(R.id.t3_widget_root, it) } + openAppIntent(context, id, snapshot, forCollection = true)?.let { + views.setPendingIntentTemplate(R.id.t3_widget_rows, it) + } + val items = RemoteViews.RemoteCollectionItems.Builder() + rows.forEachIndexed { index, (provider, window) -> + items.addItem(index.toLong(), rowView(context, provider, window)) + } + views.setRemoteAdapter(R.id.t3_widget_rows, items.build()) + views.setEmptyView(R.id.t3_widget_rows, R.id.t3_widget_empty) + val checkedAt = snapshot?.optLong("checkedAt") ?: 0 + val checked = if (checkedAt > 0) { val formatted = DateFormat.getDateTimeInstance( DateFormat.SHORT, DateFormat.SHORT ).format(Date(checkedAt)) - val more = if (remaining > 0) { - context.getString(R.string.t3_subscription_widget_more, remaining) - } else { - "" - } - val checked = if (checkedAt > 0) { - context.getString(R.string.t3_subscription_widget_as_of, formatted) - } else { - context.getString(R.string.t3_subscription_widget_unknown_check) - } - views.setTextViewText(R.id.t3_widget_footer, checked + more) + context.getString(R.string.t3_subscription_widget_last_checked, formatted) + } else { + context.getString(R.string.t3_subscription_widget_unknown_check) } + views.setTextViewText(R.id.t3_widget_footer, checked) val alarms = context.getSystemService(AlarmManager::class.java) alarms.cancel(expiryIntent(context)) // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. @@ -118,7 +118,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() { manager.updateAppWidget(id, views) } - private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { + private fun openAppIntent( + context: Context, + id: Int, + snapshot: JSONObject?, + forCollection: Boolean = false + ): PendingIntent? { // Target this variant's launcher so co-installed builds cannot steal the tap. val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null @@ -129,9 +134,14 @@ class SubscriptionUsageWidget : AppWidgetProvider() { intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP return PendingIntent.getActivity( context, - id, + id * 2 + if (forCollection) 1 else 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or if (forCollection) { + // Collection rows use fill-in intents with an explicit app target. + PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_IMMUTABLE + } ) } @@ -153,6 +163,7 @@ class SubscriptionUsageWidget : AppWidgetProvider() { val reset = window?.optString("reset") ?: context.getString(R.string.t3_subscription_widget_refresh) child.setTextViewText(R.id.t3_widget_reset, reset) + child.setOnClickFillInIntent(R.id.t3_widget_row, Intent()) child.setContentDescription( R.id.t3_widget_row, "$label. $windowLabel. $percent. $reset. $detail" diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml index 58e55bc2c81e..bf800d249fe0 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml @@ -1,9 +1,10 @@ - - - - + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml new file mode 100644 index 000000000000..dc1751bcc4f0 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml new file mode 100644 index 000000000000..8d74a0fd7d17 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml @@ -0,0 +1,3 @@ + + false + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml index 7ef70aa44a46..586b82595345 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml @@ -1,11 +1,15 @@ Last checked unavailable Subscription usage + Subscription usage (%1$d) + + Subscription usage, %1$d entry + Subscription usage, %1$d entries + Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. - Open T3 Code and connect an environment to see limits. + No subscription limits available. Open T3 Code to connect. Tap to open Usage Open app to refresh %1$d%% remaining - As of %1$s - · +%1$d more + Last checked %1$s diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index d85d9db7d780..eef05ee94f40 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -140,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "checkmark.circle": IconCircleCheck, circle: IconCircle, clock: IconClock, + timer: IconClock, ticket: IconTicket, cloud: IconCloud, cube: IconBox, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index c480d0be48f1..05070397cef5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -49,6 +49,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, @@ -206,6 +207,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4fb442b94273..7f4bfcf3a1c4 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -96,6 +96,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSetThreadAutoSettle: ( + thread: EnvironmentThreadShell, + enabled: boolean, + ) => Promise; readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -378,6 +382,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onUnpinThread], ); + const handleSetThreadAutoSettle = useCallback( + (thread: EnvironmentThreadShell, enabled: boolean) => { + void props.onSetThreadAutoSettle(thread, enabled); + }, + [props.onSetThreadAutoSettle], + ); const handleRegenerateThreadTitle = useCallback( (thread: EnvironmentThreadShell) => { void props.onRegenerateThreadTitle(thread); @@ -456,6 +466,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const autoSettleOptOutEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadAutoSettleOptOut === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -726,6 +745,7 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) @@ -738,6 +758,7 @@ export function HomeScreen(props: HomeScreenProps) { onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onSetThreadAutoSettle={handleSetThreadAutoSettle} onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -758,6 +779,8 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleUnsettleThread, + handleSetThreadAutoSettle, + autoSettleOptOutEnvironmentIds, pinningEnvironmentIds, machineByEnvironmentId, pinReorderEnvironmentIds, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 9b599110883c..4a43facc6172 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -59,6 +59,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en ); } +function environmentSupportsAutoSettleOptOut( + environmentId: EnvironmentThreadShell["environmentId"], +) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadAutoSettleOptOut === true + ); +} + function environmentSupportsTitleRegeneration( environmentId: EnvironmentThreadShell["environmentId"], ) { @@ -237,6 +246,11 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + /** Sets per-thread automatic settlement on or off. */ + readonly setThreadAutoSettle: ( + thread: EnvironmentThreadShell, + enabled: boolean, + ) => Promise; readonly moveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -249,6 +263,9 @@ export function useThreadListActions(): { const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); + const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -435,6 +452,34 @@ export function useThreadListActions(): { }, [unpinMutation], ); + const setThreadAutoSettle = useCallback( + async (thread: EnvironmentThreadShell, enabled: boolean) => { + if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { + Alert.alert( + "Could not update auto-settle", + "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.", + ); + return false; + } + selectionHaptic(); + const result = await setAutoSettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, enabled }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not update auto-settle", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The auto-settle setting could not be changed.", + ); + return false; + } + return true; + }, + [setAutoSettleMutation], + ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); @@ -698,6 +743,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts index 6b1f61815b37..c4b35525c782 100644 --- a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts @@ -25,6 +25,9 @@ export async function stageShowcaseAgentActivity( ios: { allowAlert: true, allowBadge: true, allowSound: true }, }); if (!permission.granted) return `notification permission ${permission.status}`; + // A previous appearance's pass left its alert delivered; it would stack + // under the new one. + await Notifications.dismissAllNotificationsAsync(); if (Platform.OS === "android") { return ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 160392fdd5d9..c478bae6312a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -150,6 +150,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, @@ -334,6 +335,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const autoSettleOptOutEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadAutoSettleOptOut === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -766,6 +776,7 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) @@ -778,6 +789,7 @@ function ThreadNavigationSidebarPane( onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -829,6 +841,8 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, + autoSettleOptOutEnvironmentIds, + setThreadAutoSettle, projectByKey, projectTitleByProjectKey, regenerateThreadTitle, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 43c5de09c726..e2dd3cc725df 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -493,6 +493,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; + readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -500,6 +501,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread.auto-settle.set. */ + readonly autoSettleOptOutSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; /** Server supports reordering this card's section. */ @@ -536,6 +539,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onSetThreadAutoSettle, onMoveThread, } = props; const snoozedRow = props.snoozed === true; @@ -583,6 +587,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleSetAutoSettle = useCallback( + (enabled: boolean) => onSetThreadAutoSettle(thread, enabled), + [onSetThreadAutoSettle, thread], + ); const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); @@ -661,6 +669,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); + // A submenu with the current option checked, matching web. This is a + // per-thread setting, not a lifecycle verb. + const autoSettleMenuItems = useMemo( + () => + props.autoSettleOptOutSupported + ? [ + { + id: "auto-settle", + title: "Auto-settle behavior", + image: "timer", + subactions: [ + { + id: "auto-settle:enabled", + title: "Enabled", + state: thread.autoSettleDisabledAt == null ? "on" : "off", + }, + { + id: "auto-settle:disabled", + title: "Disabled", + state: thread.autoSettleDisabledAt == null ? "off" : "on", + }, + ], + } satisfies MenuAction, + ] + : [], + [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], + ); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -682,19 +717,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); + // Settled and snoozed rows keep the setting too, matching web where every + // row shares one menu builder. const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, @@ -702,13 +741,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (action) => action.id !== "move-up" && action.id !== "move-down", ), ...titleMenuItems, + ...autoSettleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleMenuItems], + () => [ + SNOOZED_MENU_ACTIONS[0]!, + ...titleMenuItems, + ...autoSettleMenuItems, + SNOOZED_MENU_ACTIONS[1]!, + ], + [autoSettleMenuItems, titleMenuItems], ); const legacyMenuActions = useMemo( () => [ @@ -727,6 +772,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true); + if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false); if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); @@ -764,6 +811,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handlePin, handleSettle, handleSnooze, + handleSetAutoSettle, handleUnpin, handleUnsettle, handleUnsnooze, diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d922eb7f6d5c..108afbbc9416 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -74,6 +74,7 @@ function threadDetailToShell( settledAt: thread.settledAt, unsettledAt: thread.unsettledAt, activeOrderKey: thread.activeOrderKey, + autoSettleDisabledAt: thread.autoSettleDisabledAt, pinnedAt: thread.pinnedAt, pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index c8cde5d444fe..a32454e5ff83 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -235,7 +235,11 @@ function SubscriptionUsage( spacing={accessory || dense ? 2 : 6} modifiers={props.url ? [widgetURL(props.url)] : []} > - {compact ? ( + {providers.length === 0 ? ( + + No subscription limits available. + + ) : compact ? ( {columns} diff --git a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx index 84dce5d515ec..4a805b48ce22 100644 --- a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import * as Linking from "expo-linking"; import { useEffect } from "react"; +import { Platform } from "react-native"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations } from "../state/presentation"; import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; @@ -13,6 +14,8 @@ const snapshotAtom = Atom.make((get) => buildSubscriptionUsageSnapshot( get(environmentPresentations.presentationsAtom), Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), + // Android scrolls the full list; iOS stores a bounded widget timeline. + Platform.OS === "android" ? Infinity : 6, ), ).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 65d4c9924f66..8ab3d6191ef5 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -65,18 +65,63 @@ describe("subscription widget snapshots", () => { expect(snapshot.url).toBe(deepLink); expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); }); - it("clears data after removing environments and hides disabled providers", () => { - expect( - buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every( - (p) => p.windows.length === 0, - ), - ).toBe(true); + it("clears data after removing environments", () => { + expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).providers).toEqual([]); + }); + it.each<{ name: string; overrides: Partial }>([ + { name: "disabled", overrides: { enabled: false } }, + { + name: "missing", + overrides: { installed: false, status: "error", usageLimits: undefined }, + }, + { + name: "API-key", + overrides: { + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }, + }, + ])("hides $name providers", ({ overrides }) => { expect( - buildSubscriptionUsageSnapshot( - presentations([provider({ enabled: false })]), - deepLink, - ).providers.every((p) => p.windows.length === 0), - ).toBe(true); + buildSubscriptionUsageSnapshot(presentations([provider(overrides)]), deepLink).providers, + ).toEqual([]); + }); + it.each([ + { name: "Codex", driver: "codex" }, + { name: "Claude", driver: "claudeAgent" }, + ])("only shows $name when $name and OpenCode are configured", ({ name, driver }) => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + }), + provider({ + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + usageLimits: undefined, + }), + ]), + deepLink, + ); + expect(snapshot.providers).toHaveLength(1); + expect(snapshot.providers[0]).toMatchObject({ + name, + totalWindows: 1, + windows: [{ remaining: 60 }], + }); + expect(subscriptionUsageTimeline(snapshot, now + 15 * 60_000)[0]?.props.providers).toEqual([ + expect.objectContaining({ name, totalWindows: 0, windows: [] }), + ]); + }); + it("keeps an enabled provider visible before its first usage read", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: undefined })]), + deepLink, + ); + expect(snapshot.checkedAt).toBe(0); + expect(snapshot.providers).toEqual([ + { name: "Codex", detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }, + ]); }); it("uses upstream deduplication for a native account also present in a proxy hub", () => { const input = new Map([ @@ -151,6 +196,21 @@ describe("subscription widget snapshots", () => { expect(snapshot.providers[0]?.totalWindows).toBe(20); expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5); }); + it("includes every limit for the scrollable Android widget", () => { + const windows = Array.from({ length: 20 }, (_, index) => ({ + ...window, + id: `${index}`, + usedPercent: index * 5, + })); + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows } })]), + deepLink, + Infinity, + ); + expect(snapshot.providers[0]?.windows.map((window) => window.remaining)).toEqual( + Array.from({ length: 20 }, (_, index) => 5 + index * 5), + ); + }); it("marks unknown or distant reset times stale after fifteen minutes", () => { const snapshot = buildSubscriptionUsageSnapshot( presentations([ diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 826124e1053f..44f2c6ef4bed 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -46,6 +46,8 @@ export function createWidgetRefresher(refresh: (id: Id) => Promise) function subscriptionUsageProps( accounts: readonly LimitAccount[], now: number, + configuredDrivers: ReadonlySet, + maxWindowsPerProvider: number, ): SubscriptionUsageSnapshot { const pools = collectLimitPools(accounts, now); const checked = accounts @@ -53,57 +55,68 @@ function subscriptionUsageProps( .map((account) => Date.parse(account.limits.checkedAt)); return { checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0, - providers: (["codex", "claudeAgent"] as const).map((driver) => { - const pool = pools.find((candidate) => candidate.driver === driver); - const name = driver === "codex" ? "Codex" : "Claude"; - if (!pool) - return { name, detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }; - const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); - const expiresAt = Math.min( - checkedAt + SNAPSHOT_MAX_AGE, - ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), - ); - const fresh = Number.isFinite(expiresAt) && expiresAt > now; - const sortedWindows = [...pool.windows].sort( - (a, b) => a.remainingPercent - b.remainingPercent, - ); - // Keep a session and weekly limit when scoped limits fill the storage budget. - const selectedWindows = [ - ...new Set([ - sortedWindows.find((window) => window.kind === "session"), - sortedWindows.find((window) => window.kind === "weekly"), - ...sortedWindows, - ]), - ] - .filter((window) => window !== undefined) - .slice(0, 6) - .sort((a, b) => a.remainingPercent - b.remainingPercent); - return { - name, - detail: !fresh - ? "Open T3 to refresh" - : pool.accounts.length > 1 - ? `${pool.accounts.length} accounts · pooled` - : "Subscription remaining", - expiresAt: fresh ? expiresAt : 0, - totalWindows: fresh ? pool.windows.length : 0, - windows: fresh - ? selectedWindows.map((window) => ({ - kind: window.kind, - label: window.label, - remaining: Math.round(window.remainingPercent), - reset: window.resets[0] - ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })}` - : "Reset time unavailable", - })) - : [], - }; - }), + providers: (["codex", "claudeAgent"] as const) + .filter( + (driver) => + configuredDrivers.has(driver) || accounts.some((account) => account.driver === driver), + ) + .map((driver) => { + const pool = pools.find((candidate) => candidate.driver === driver); + const name = driver === "codex" ? "Codex" : "Claude"; + if (!pool) + return { + name, + detail: "No limits available", + windows: [], + expiresAt: 0, + totalWindows: 0, + }; + const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); + const expiresAt = Math.min( + checkedAt + SNAPSHOT_MAX_AGE, + ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), + ); + const fresh = Number.isFinite(expiresAt) && expiresAt > now; + const sortedWindows = [...pool.windows].sort( + (a, b) => a.remainingPercent - b.remainingPercent, + ); + // Keep a session and weekly limit when scoped limits fill the storage budget. + const selectedWindows = [ + ...new Set([ + sortedWindows.find((window) => window.kind === "session"), + sortedWindows.find((window) => window.kind === "weekly"), + ...sortedWindows, + ]), + ] + .filter((window) => window !== undefined) + .slice(0, maxWindowsPerProvider) + .sort((a, b) => a.remainingPercent - b.remainingPercent); + return { + name, + detail: !fresh + ? "Open T3 to refresh" + : pool.accounts.length > 1 + ? `${pool.accounts.length} accounts · pooled` + : "Subscription remaining", + expiresAt: fresh ? expiresAt : 0, + totalWindows: fresh ? pool.windows.length : 0, + windows: fresh + ? selectedWindows.map((window) => ({ + kind: window.kind, + label: window.label, + remaining: Math.round(window.remainingPercent), + reset: window.resets[0] + ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}` + : "Reset time unavailable", + })) + : [], + }; + }), }; } @@ -111,9 +124,31 @@ function subscriptionUsageProps( export function buildSubscriptionUsageSnapshot( presentations: LimitPresentations, url: string, + maxWindowsPerProvider = 6, ): SubscriptionUsageSnapshot { // Freshness is evaluated at publication/render time, not on unrelated config emissions. - return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url }; + const configuredDrivers = new Set( + [...presentations.values()].flatMap((presentation) => + (presentation.serverConfig?.providers ?? []) + // Servers report default-enabled drivers even when their CLI is missing. + .filter( + (provider) => + provider.enabled && + provider.installed && + provider.usageLimits?.unavailable?.reason !== "unsupported", + ) + .map((provider) => provider.driver), + ), + ); + return { + ...subscriptionUsageProps( + collectLimitAccounts(presentations), + 0, + configuredDrivers, + maxWindowsPerProvider, + ), + url, + }; } export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 9fbdeee03c86..17a464be2f10 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,8 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; +const emitBackgroundToolDuringAnswer = + process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; @@ -941,6 +943,47 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitBackgroundToolDuringAnswer) { + // A command backgrounded earlier reports progress and then finishes + // while the next answer is still streaming. + const toolCallId = "background-1"; + const say = (text: string) => + agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, + }); + const progress = (status: "in_progress" | "completed", stdout: string) => + agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status, + rawOutput: { stdout }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 3 && echo done" }, + }, + }); + yield* say("| a | b |\n|---|---|\n| 1 "); + yield* progress("in_progress", "."); + yield* say("| x |\n"); + yield* progress("completed", "done"); + yield* say("| 2 | y |\n"); + // Agents can repeat a terminal update after the call finished. + yield* progress("completed", "done"); + yield* say("| 3 | z |"); + return { stopReason: "end_turn" }; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts index 39f904b47b89..d59b89875e79 100644 --- a/apps/server/src/cloud/CliState.test.ts +++ b/apps/server/src/cloud/CliState.test.ts @@ -8,6 +8,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerConfig } from "../config.ts"; import * as CliState from "./CliState.ts"; import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -24,6 +25,7 @@ const persistedCloudLinkSecrets = [ RELAY_ENVIRONMENT_CREDENTIAL_SECRET, CLOUD_MINT_PUBLIC_KEY, CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, PUBLISH_AGENT_ACTIVITY_SECRET, ] as const; diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index 9af9a032f856..dc77609ccc92 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -3,6 +3,7 @@ import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -67,6 +68,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), secrets.remove(CLOUD_MINT_PUBLIC_KEY), secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), ], { concurrency: "unbounded" }, diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index ba2cf5c5ac05..a38989a4733d 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -7,10 +7,12 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -62,6 +64,7 @@ function makeHandle(input: { readonly onKill: () => void; readonly isRunning?: () => boolean; readonly exitCode?: Effect.Effect; + readonly output?: Stream.Stream; }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(input.pid), @@ -75,13 +78,70 @@ function makeHandle(input: { stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, - all: Stream.empty, + all: input.output ?? Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } describe("CloudManagedEndpointRuntime", () => { + it("retries connector startup failures but stops for unsupported runtimes", () => { + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ + status: "failed", + failure: "not-installed", + reason: "The relay client is not installed.", + }), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ + status: "failed", + failure: "spawn-failed", + reason: "spawn failed", + }), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ + status: "failed", + failure: "unsupported-platform", + reason: "Relay client is unsupported on linux-arm.", + }), + ).toBe(false); + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ status: "unsupported" }), + ).toBe(false); + }); + + it.effect("serializes updates to persisted cloud link state", () => + Effect.gen(function* () { + const firstEntered = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondEntered = yield* Deferred.make(); + const runtime = yield* buildCloudManagedEndpointRuntime( + ChildProcessSpawner.make(() => Effect.die("unused")), + ); + + const first = yield* runtime + .withLinkStateLock( + Deferred.succeed(firstEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(firstEntered); + + const second = yield* runtime + .withLinkStateLock(Deferred.succeed(secondEntered, undefined)) + .pipe(Effect.forkChild); + expect(yield* Deferred.isDone(secondEntered)).toBe(false); + + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + expect(yield* Deferred.isDone(secondEntered)).toBe(true); + }), + ); + it("classifies Cloudflare connection and warning output", () => { expect( ManagedEndpointRuntime.classifyRelayClientOutput( @@ -109,6 +169,125 @@ describe("CloudManagedEndpointRuntime", () => { ).toBe("warning"); }); + it("recognizes tunnel authorization failures without matching ordinary transport errors", () => { + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-09-15T06:30:43Z ERR Register tunnel error from server side error="Failed to get tunnel" connIndex=0 event=0 ip=198.41.200.23', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="Unauthorized: Record for tunnel not found" connIndex=0', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="Unauthorized: Invalid tunnel secret" connIndex=0', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="connection timed out" connIndex=0', + ), + ).toBe(false); + }); + + it.effect("keeps recovery requests sent before the server starts consuming them", () => + Effect.gen(function* () { + const runtime = yield* buildCloudManagedEndpointRuntime( + ChildProcessSpawner.make(() => Effect.die("unused")), + ); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "tunnel-1", + }; + + yield* runtime.requestRecovery(config); + + expect(Option.getOrNull(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); + }), + ); + + it.effect("recovers a rejected tunnel without waiting for the connector to exit", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const firstBatchObserved = yield* Deferred.make(); + const secondBatchObserved = yield* Deferred.make(); + const recoveryRequested = yield* Deferred.make(); + const recoveryRetried = yield* Deferred.make(); + let recoveryRequestCount = 0; + const spawned: Array = []; + const encoder = new TextEncoder(); + const connectorOutput = Stream.fromQueue(output).pipe( + Stream.tap((chunk) => { + const line = new TextDecoder().decode(chunk); + if (line === "first checkpoint\n") { + return Deferred.succeed(firstBatchObserved, undefined).pipe(Effect.asVoid); + } + if (line === "second checkpoint\n") { + return Deferred.succeed(secondBatchObserved, undefined).pipe(Effect.asVoid); + } + return Effect.void; + }), + ); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = 600; + spawned.push(pid); + const handle = makeHandle({ pid, onKill: () => {}, output: connectorOutput }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "deleted-tunnel", + }; + const rejectedLine = + '2026-09-15T06:30:43Z ERR Register tunnel error from server side error="Failed to get tunnel" connIndex=0 event=0 ip=198.41.200.23\n'; + + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((requested) => { + recoveryRequestCount += 1; + return Deferred.succeed( + recoveryRequestCount === 1 ? recoveryRequested : recoveryRetried, + requested, + ).pipe(Effect.asVoid); + }), + Effect.forkChild, + ); + yield* runtime.applyConfig(config); + + yield* Queue.offer(output, encoder.encode(rejectedLine.repeat(3))); + yield* Queue.offer(output, encoder.encode("first checkpoint\n")); + yield* Deferred.await(firstBatchObserved); + expect(yield* Deferred.isDone(recoveryRequested)).toBe(false); + + yield* Queue.offer( + output, + encoder.encode( + "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0\n" + + rejectedLine.repeat(3), + ), + ); + yield* Queue.offer(output, encoder.encode("second checkpoint\n")); + yield* Deferred.await(secondBatchObserved); + expect(yield* Deferred.isDone(recoveryRequested)).toBe(false); + + yield* Queue.offer(output, encoder.encode(rejectedLine)); + + expect(yield* Deferred.await(recoveryRequested)).toEqual(config); + + yield* Queue.offer(output, encoder.encode(rejectedLine.repeat(4))); + + expect(yield* Deferred.await(recoveryRetried)).toEqual(config); + expect(spawned).toEqual([600]); + }), + ); + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; @@ -156,8 +335,8 @@ describe("CloudManagedEndpointRuntime", () => { expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]); expect(spawned.map((command) => command.args)).toEqual([ - ["tunnel", "run"], - ["tunnel", "run"], + ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], + ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], ]); expect(spawned.map((command) => command.options.env?.TUNNEL_TOKEN)).toEqual([ "token-1", @@ -378,6 +557,37 @@ describe("CloudManagedEndpointRuntime", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("a recovery that returns the same config keeps the crash backoff", () => + Effect.gen(function* () { + const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(900, 4); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "same-token", + tunnelId: "same-tunnel", + }; + // The startup consumer re-applies whatever the relay hands back. When the + // relay confirms the current tunnel, that must not look like a config change. + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((requested) => runtime.applyConfig(requested).pipe(Effect.asVoid)), + Effect.forkChild, + ); + + yield* runtime.applyConfig(config); + yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[1]!); + expect(spawned).toEqual([900, 901]); + + // Second rapid crash still waits out the base delay. + yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); + yield* TestClock.adjust(Duration.millis(999)); + expect(spawned).toEqual([900, 901]); + yield* TestClock.adjust(Duration.millis(1)); + yield* Deferred.await(spawnSignals[2]!); + expect(spawned).toEqual([900, 901, 902]); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("an explicit config change clears the backoff and preempts a delayed restart", () => Effect.gen(function* () { const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(800, 3); @@ -510,6 +720,7 @@ describe("CloudManagedEndpointRuntime", () => { expect(status).toEqual({ status: "failed", providerKind: "cloudflare_tunnel", + failure: "not-installed", reason: "The relay client is not installed.", }); expect(spawn).not.toHaveBeenCalled(); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index cc657bdebf1b..21091c5c446e 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -6,7 +6,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Semaphore from "effect/Semaphore"; @@ -15,22 +15,6 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; - -function bytesToString(bytes: Uint8Array): string { - return new TextDecoder().decode(bytes); -} - -const readRuntimeConfig = Effect.gen(function* () { - const secrets = yield* ServerSecretStore.ServerSecretStore; - const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if (Option.isNone(bytes)) { - return null; - } - return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes.value))); -}); - export type CloudManagedEndpointRuntimeStatus = | { readonly status: "disabled"; @@ -38,6 +22,7 @@ export type CloudManagedEndpointRuntimeStatus = | { readonly status: "failed"; readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; + readonly failure: "unsupported-platform" | "not-installed" | "spawn-failed"; readonly reason: string; readonly tunnelId?: string; readonly tunnelName?: string; @@ -60,6 +45,9 @@ export class CloudManagedEndpointRuntime extends Context.Service< readonly applyConfig: ( config: RelayManagedEndpointRuntimeConfig | null, ) => Effect.Effect; + readonly recoveryRequests: Stream.Stream; + readonly requestRecovery: (config: RelayManagedEndpointRuntimeConfig) => Effect.Effect; + readonly withLinkStateLock: (effect: Effect.Effect) => Effect.Effect; } >()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} @@ -79,6 +67,8 @@ interface ActiveConnector { const RELAY_RESTART_STABLE_UPTIME_MS = 30_000; const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; +// Newly created tunnels can fail authorization briefly while Cloudflare propagates their token. +const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { @@ -90,6 +80,32 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" return /\b(?:ERR|WRN|FTL|PNC)\b/u.test(line) ? "warning" : "debug"; } +/** + * Cloudflare's edge rejects a connector whose tunnel was deleted or whose + * token no longer matches. Current edge output is + * `error="Failed to get tunnel"` with no prefix; older edges prefixed the + * same messages with `Unauthorized:`. Match both so recovery fires on either. + */ +export function isRejectedRelayClientTunnelOutput(line: string): boolean { + return ( + /\bRegister tunnel error from server side\b/iu.test(line) && + /error="(?:Unauthorized:\s*)?(?:Failed to get tunnel|Record for tunnel not found|Invalid tunnel secret)"/iu.test( + line, + ) + ); +} + +/** Connector startup failures can clear after installation or a later spawn attempt. */ +export function isRetryableManagedEndpointRuntimeStatus(status: unknown): boolean { + if (typeof status !== "object" || status === null || !("status" in status)) { + return false; + } + if (status.status !== "failed" || !("failure" in status)) { + return false; + } + return status.failure === "not-installed" || status.failure === "spawn-failed"; +} + function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { return JSON.stringify({ providerKind: config.providerKind, @@ -117,8 +133,10 @@ export const make = Effect.gen(function* () { const relayClient = yield* RelayClient.RelayClient; const activeRef = yield* Ref.make(null); const desiredConfigRef = yield* Ref.make(null); + const recoveryRequests = yield* Queue.sliding(1); const reconcileSemaphore = yield* Semaphore.make(1); const restartDelayRef = yield* Ref.make(0); + const linkStateSemaphore = yield* Semaphore.make(1); let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { @@ -191,6 +209,7 @@ export const make = Effect.gen(function* () { tunnelId: connector.config.tunnelId, tunnelName: connector.config.tunnelName, }); + yield* Queue.offer(recoveryRequests, connector.config); yield* reconcileConfig(desiredConfig); }), ); @@ -198,8 +217,10 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); - const observeConnectorOutput = (connector: ActiveConnector) => - connector.child.all.pipe( + const observeConnectorOutput = (connector: ActiveConnector) => { + let rejectedRegistrations = 0; + + return connector.child.all.pipe( Stream.decodeText(), Stream.splitLines, Stream.map((line) => line.trim()), @@ -214,8 +235,22 @@ export const make = Effect.gen(function* () { }; switch (classifyRelayClientOutput(line)) { case "connected": + rejectedRegistrations = 0; return Effect.logInfo("Relay client tunnel connection registered", attributes); case "warning": + if (isRejectedRelayClientTunnelOutput(line)) { + rejectedRegistrations += 1; + if (rejectedRegistrations >= TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY) { + rejectedRegistrations = 0; + return Effect.logWarning( + "Relay client tunnel was rejected; requesting recovery", + attributes, + ).pipe( + Effect.andThen(Queue.offer(recoveryRequests, connector.config)), + Effect.asVoid, + ); + } + } return Effect.logWarning("Relay client reported a transport warning", attributes); case "debug": return Effect.logDebug("Relay client output", attributes); @@ -230,6 +265,7 @@ export const make = Effect.gen(function* () { }), ), ); + }; reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { if (!config || config.providerKind !== "cloudflare_tunnel") { @@ -261,6 +297,7 @@ export const make = Effect.gen(function* () { return { status: "failed", providerKind: "cloudflare_tunnel", + failure: executable.status === "unsupported" ? "unsupported-platform" : "not-installed", reason: executable.status === "unsupported" ? `Relay client is unsupported on ${executable.platform}-${executable.arch}.` @@ -273,16 +310,20 @@ export const make = Effect.gen(function* () { const connectorScope = yield* Scope.make("sequential"); const child = yield* spawner .spawn( - ChildProcess.make(executable.executablePath, ["tunnel", "run"], { - detached: false, - env: { - ...process.env, - TUNNEL_TOKEN: config.connectorToken, + ChildProcess.make( + executable.executablePath, + ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], + { + detached: false, + env: { + ...process.env, + TUNNEL_TOKEN: config.connectorToken, + }, + shell: false, + stderr: "pipe", + stdout: "pipe", }, - shell: false, - stderr: "pipe", - stdout: "pipe", - }), + ), ) .pipe( Effect.provideService(Scope.Scope, connectorScope), @@ -303,6 +344,7 @@ export const make = Effect.gen(function* () { Effect.as({ status: "failed", providerKind: "cloudflare_tunnel", + failure: "spawn-failed", reason: String(cause), ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), @@ -338,6 +380,7 @@ export const make = Effect.gen(function* () { return { status: "failed", providerKind: "cloudflare_tunnel", + failure: "spawn-failed", reason: "Relay client did not start.", ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), @@ -347,26 +390,31 @@ export const make = Effect.gen(function* () { const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")( (config: RelayManagedEndpointRuntimeConfig | null) => reconcileSemaphore.withPermits(1)( - // An explicit config change starts over with a fresh backoff. - Ref.set(restartDelayRef, 0).pipe( - Effect.andThen(Ref.set(desiredConfigRef, config)), - Effect.andThen(reconcileConfig(config)), - ), + Effect.gen(function* () { + // A real config change starts over with a fresh backoff. Recovery + // that hands back the same tunnel and token must keep the delay, or + // a crash-looping connector respawns on every recovery round trip. + const desired = yield* Ref.get(desiredConfigRef); + const unchanged = + desired !== null && + config !== null && + runtimeConfigKey(desired) === runtimeConfigKey(config); + if (!unchanged) { + yield* Ref.set(restartDelayRef, 0); + } + yield* Ref.set(desiredConfigRef, config); + return yield* reconcileConfig(config); + }), ), ); const runtime = CloudManagedEndpointRuntime.of({ applyConfig, + recoveryRequests: Stream.fromQueue(recoveryRequests), + requestRecovery: (config) => Queue.offer(recoveryRequests, config).pipe(Effect.asVoid), + withLinkStateLock: linkStateSemaphore.withPermits(1), }); - const initialConfig = yield* readRuntimeConfig.pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( - Effect.as(null), - ), - ), - ); - yield* runtime.applyConfig(initialConfig); yield* Effect.addFinalizer(() => runtime.applyConfig(null)); return runtime; }); diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 2eff693f61e6..9b1b281ba2da 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -1,4 +1,7 @@ -import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import { + RelayManagedEndpointOrigin, + RelayManagedEndpointRuntimeConfig, +} from "@t3tools/contracts/relay"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -7,6 +10,7 @@ import type * as ServerSecretStore from "../auth/ServerSecretStore.ts"; export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; +export const CLOUD_ENDPOINT_CONFIRMED_ORIGIN = "cloud-endpoint-confirmed-origin"; export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; export const RELAY_URL_SECRET = "cloud-relay-url"; export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; @@ -21,6 +25,19 @@ export const decodeRuntimeConfig = Schema.decodeUnknownOption( Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), ); +export const ManagedEndpointConfirmedOrigin = Schema.Struct({ + config: RelayManagedEndpointRuntimeConfig, + origin: RelayManagedEndpointOrigin, +}); + +export const encodeConfirmedOriginJson = Schema.encodeEffect( + Schema.fromJsonString(ManagedEndpointConfirmedOrigin), +); + +export const decodeConfirmedOrigin = Schema.decodeUnknownOption( + Schema.fromJsonString(ManagedEndpointConfirmedOrigin), +); + export function isAgentActivityPublishingEnabledValue(value: string | null): boolean { return value === "true"; } diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 0f24e6f34176..ab4cdcc7d25d 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -1,12 +1,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse, @@ -14,7 +20,7 @@ import { type HttpClientRequest, } from "effect/unstable/http"; -import { EnvironmentId } from "@t3tools/contracts"; +import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -29,16 +35,37 @@ import { import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; -import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts"; +import { + RelayManagedEndpointRecoveryRegistrationRequest, + type RelayLinkProofRequest, +} from "@t3tools/contracts/relay"; +import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINKED_USER_ID, + decodeConfirmedOrigin, + decodeRuntimeConfig, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; import { consumeCloudReplayGuards, isSupportedLinkProviderKind, linkProofScopes, pendingServiceUpdateExists, + parseManagedEndpointLocalOrigin, reconcileDesiredCloudLink, + reconcileDesiredCloudLinkIfStillDesired, + recoverManagedCloudTunnel, + registerManagedCloudTunnelRecovery, releaseManagedTunnelOnShutdown, + startManagedCloudTunnelIfOriginConfirmed, } from "./http.ts"; +import { + managedTunnelStartupAction, + retryManagedTunnelRegistration, +} from "./managedTunnelStartup.ts"; +import { shouldRetryCloudLink } from "./relayResponse.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; @@ -54,6 +81,9 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); +const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect( + Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest), +); function makeSecretStore( create: ServerSecretStore.ServerSecretStore["Service"]["create"], @@ -209,6 +239,9 @@ describe("reconcileDesiredCloudLink", () => { ManagedEndpointRuntime.CloudManagedEndpointRuntime, ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ applyConfig: unusedSecretStoreOperation, + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), ), Effect.provideService( @@ -234,6 +267,39 @@ describe("reconcileDesiredCloudLink", () => { ); }); +describe("parseManagedEndpointLocalOrigin", () => { + it.each([ + { + input: "http://127.0.0.1:80", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + port: 80, + }, + { + input: "https://127.0.0.1:443", + httpBaseUrl: "https://127.0.0.1", + wsBaseUrl: "wss://127.0.0.1", + port: 443, + }, + ])("accepts an explicit default port in $input", ({ input, httpBaseUrl, wsBaseUrl, port }) => { + expect(parseManagedEndpointLocalOrigin(input)).toEqual({ + httpBaseUrl, + wsBaseUrl, + origin: { localHttpHost: "127.0.0.1", localHttpPort: port }, + }); + }); + + it.each([ + "ftp://127.0.0.1:3773", + "http://user:password@127.0.0.1:3773", + "http://127.0.0.1:3773/api", + "http://127.0.0.1:3773?mode=test", + "http://127.0.0.1:3773#fragment", + ])("rejects non-origin URL %s", (input) => { + expect(() => parseManagedEndpointLocalOrigin(input)).toThrow("Invalid local origin"); + }); +}); + describe("releaseManagedTunnelOnShutdown", () => { const cliToken: CliTokenManager.PersistedToken = { accessToken: "cli-access-token", @@ -251,7 +317,10 @@ describe("releaseManagedTunnelOnShutdown", () => { Effect.sync(() => { values.set(name, value); }), - create: unusedSecretStoreOperation, + create: (name, value) => + Effect.sync(() => { + values.set(name, value); + }), getOrCreateRandom: unusedSecretStoreOperation, remove: (name) => Effect.sync(() => { @@ -265,7 +334,9 @@ describe("releaseManagedTunnelOnShutdown", () => { readonly store: ServerSecretStore.ServerSecretStore["Service"]; readonly applyConfigCalls: Array; readonly requests: Array; + readonly onRequest?: (request: HttpClientRequest.HttpClientRequest) => Effect.Effect; readonly respond?: () => Response; + readonly respondEffect?: Effect.Effect; } // Writes the launcher's durable state file into this test's baseDir with @@ -285,6 +356,20 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); + // Writes the marker the desktop app leaves just before it stops its backend + // to install an update, and returns when it was written. + const writeDesktopUpdateRestartMarker = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfigModule.ServerConfig; + const runtimeDir = path.join(config.baseDir, "runtime"); + const markerPath = path.join(runtimeDir, DESKTOP_UPDATE_RESTART_MARKER_FILE); + yield* fs.makeDirectory(runtimeDir, { recursive: true }); + yield* fs.writeFileString(markerPath, ""); + const { mtime } = yield* fs.stat(markerPath); + return Option.getOrThrow(mtime).getTime(); + }); + const provideReleaseHarness = (harness: ReleaseHarness) => (effect: Effect.Effect) => @@ -303,10 +388,19 @@ describe("releaseManagedTunnelOnShutdown", () => { applyConfig: (config) => Effect.sync(() => { harness.applyConfigCalls.push(config); - return { - status: "disabled", - } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus; + return config === null + ? ({ + status: "disabled", + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus) + : ({ + status: "running", + providerKind: "cloudflare_tunnel", + pid: 123, + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus); }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, }), ), Effect.provideService( @@ -328,11 +422,14 @@ describe("releaseManagedTunnelOnShutdown", () => { HttpClient.make((request) => Effect.sync(() => { harness.requests.push(request); - return HttpClientResponse.fromWeb( - request, - (harness.respond ?? (() => Response.json({ ok: true })))(), - ); - }), + }).pipe( + Effect.andThen(harness.onRequest?.(request) ?? Effect.void), + Effect.andThen( + harness.respondEffect ?? + Effect.sync(() => (harness.respond ?? (() => Response.json({ ok: true })))()), + ), + Effect.map((response) => HttpClientResponse.fromWeb(request, response)), + ), ), ), // The release consults the launcher state file under the configured @@ -348,10 +445,27 @@ describe("releaseManagedTunnelOnShutdown", () => { // The persisted state of a CLI-managed link whose tunnel is releasable. const managedLinkSecrets = [ [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], + [CLOUD_ENDPOINT_CONFIRMED_ORIGIN, "confirmed-origin"], [RELAY_URL_SECRET, "https://relay.example.test"], [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], ] as const; + it.effect("does not recreate a link that was unlinked while startup registration retried", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + // Registration started while this marker existed. Unlink removes it + // before startup receives the relay's final not_linked response. + values.delete(CLOUD_CLI_DESIRED_LINK_SECRET); + + expect(yield* reconcileDesiredCloudLinkIfStillDesired("http://127.0.0.1:3773")).toBeNull(); + expect(requests).toEqual([]); + expect(applyConfigCalls).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + it.effect("stops the connector, releases the relay tunnel, and drops the dead token", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; @@ -370,6 +484,7 @@ describe("releaseManagedTunnelOnShutdown", () => { ); expect(request.headers.authorization).toBe("Bearer cli-access-token"); expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); + expect(values.has(CLOUD_ENDPOINT_CONFIRMED_ORIGIN)).toBe(false); }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); @@ -454,6 +569,38 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); + it.effect("keeps the tunnel once when the desktop app restarts it for an update", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + yield* TestClock.setTime(yield* writeDesktopUpdateRestartMarker); + + expect(yield* releaseManagedTunnelOnShutdown()).toBe(false); + expect(requests).toEqual([]); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + + // The shutdown consumed the marker, so a later quit releases the tunnel. + expect(yield* releaseManagedTunnelOnShutdown()).toBe(true); + expect(requests).toHaveLength(1); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("releases the tunnel when the desktop update marker is stale", () => { + const { store } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const writtenAt = yield* writeDesktopUpdateRestartMarker; + yield* TestClock.setTime(writtenAt + Duration.toMillis(Duration.minutes(2))); + + expect(yield* releaseManagedTunnelOnShutdown()).toBe(true); + expect(requests).toHaveLength(1); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + it.effect("still releases a pending update when the launcher is stopping", () => { // `t3 service uninstall` or `systemctl stop` during the pending window: // the launcher writes its stop marker before signalling the child, so no @@ -579,6 +726,501 @@ describe("releaseManagedTunnelOnShutdown", () => { }), ); }); + + it.effect("registers an existing tunnel and starts the confirmed connector", () => { + const { store } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* registerManagedCloudTunnelRecovery("http://127.0.0.1:3773")).toMatchObject({ + status: "ready", + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe( + "https://relay.example.test/v1/environments/env_123/tunnel/recovery", + ); + expect(requests[0]?.headers.authorization).toBe("Bearer environment-credential"); + const body = requests[0]?.body; + expect(body?._tag).toBe("Uint8Array"); + if (body?._tag === "Uint8Array") { + expect( + yield* decodeManagedTunnelRecoveryRegistration(new TextDecoder().decode(body.body)), + ).toMatchObject({ + cloudUserId: "user-123", + tunnelId: "existing-tunnel", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + } + expect(applyConfigCalls).toHaveLength(1); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => Response.json({ status: "ready" }), + }), + ); + }); + + it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "existing-token", + tunnelId: "existing-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)], + [ + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + JSON.stringify({ + config, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + let relayAvailable = false; + const localOrigin = "http://127.0.0.1:4884"; + + return Effect.gen(function* () { + const fallbackStarted = yield* Deferred.make(); + const firstFailure = yield* Deferred.make(); + expect(yield* startManagedCloudTunnelIfOriginConfirmed(localOrigin)).toBe(false); + const registration = yield* Effect.forkChild( + retryManagedTunnelRegistration( + registerManagedCloudTunnelRecovery(localOrigin).pipe( + Effect.tapError(() => Deferred.succeed(firstFailure, undefined)), + ), + shouldRetryCloudLink, + startManagedCloudTunnelIfOriginConfirmed(localOrigin, { + requireConfirmedOrigin: false, + }).pipe( + Effect.orDie, + Effect.tap((started) => { + expect(started).toBe(true); + return Deferred.succeed(fallbackStarted, undefined); + }), + Effect.asVoid, + ), + ), + { startImmediately: true }, + ); + yield* Deferred.await(firstFailure); + yield* TestClock.adjust("15 minutes"); + yield* Effect.raceFirst( + Deferred.await(fallbackStarted), + Fiber.join(registration).pipe( + Effect.andThen(Effect.die("Registration ended before starting the fallback")), + ), + ); + expect(applyConfigCalls).toEqual([config]); + const attemptsBeforeRecovery = requests.length; + + relayAvailable = true; + yield* TestClock.adjust("1 minute"); + expect(yield* Fiber.join(registration)).toMatchObject({ status: "ready" }); + expect(requests.length).toBeGreaterThan(attemptsBeforeRecovery); + const marker = yield* store.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + expect(Option.isSome(marker)).toBe(true); + if (Option.isSome(marker)) { + expect( + Option.getOrThrow(decodeConfirmedOrigin(new TextDecoder().decode(marker.value))), + ).toEqual({ + config, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 }, + }); + } + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => + relayAvailable + ? Response.json({ status: "ready" }) + : Response.json({ message: "relay unavailable" }, { status: 503 }), + }), + ); + }); + + it.effect( + "starts a connector with a marker for the current origin without contacting relay", + () => { + const configJson = + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}'; + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "existing-token", + tunnelId: "existing-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, configJson], + [ + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + `{"config":${configJson},"origin":{"localHttpHost":"127.0.0.1","localHttpPort":3773}}`, + ], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* startManagedCloudTunnelIfOriginConfirmed("http://127.0.0.1:3773")).toBe(true); + expect(applyConfigCalls).toEqual([config]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }, + ); + + it.effect.each([ + { name: "missing", marker: undefined, origin: "http://127.0.0.1:3773" }, + { + name: "stale", + marker: + '{"config":{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"},"origin":{"localHttpHost":"127.0.0.1","localHttpPort":3773}}', + origin: "http://127.0.0.1:4884", + }, + ])("does not start a connector with a $name origin marker", ({ marker, origin }) => { + const entries: Array = [ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', + ], + ]; + if (marker !== undefined) entries.push([CLOUD_ENDPOINT_CONFIRMED_ORIGIN, marker]); + const { store } = makeMemorySecretStore(entries); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* startManagedCloudTunnelIfOriginConfirmed(origin)).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect( + "starts the stored connector without a marker when confirmation is not required", + () => { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "existing-token", + tunnelId: "existing-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect( + yield* startManagedCloudTunnelIfOriginConfirmed("http://127.0.0.1:3773", { + requireConfirmedOrigin: false, + }), + ).toBe(true); + expect(applyConfigCalls).toEqual([config]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }, + ); + + it.effect.each(["replaced", "removed"] as const)( + "does not activate a tunnel when its runtime config is %s during registration", + (mutation) => { + const originalConfig = + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}'; + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, originalConfig], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* registerManagedCloudTunnelRecovery("http://127.0.0.1:3773")).toEqual({ + status: "superseded", + }); + expect(applyConfigCalls).toEqual([]); + expect(values.has(CLOUD_ENDPOINT_CONFIRMED_ORIGIN)).toBe(false); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => { + if (mutation === "replaced") { + values.set( + CLOUD_ENDPOINT_RUNTIME_CONFIG, + new TextEncoder().encode( + '{"providerKind":"cloudflare_tunnel","connectorToken":"fresh-token","tunnelId":"fresh-tunnel"}', + ), + ); + } else { + values.delete(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + return Response.json({ status: "ready" }); + }, + }), + ); + }, + ); + + it.effect("requests startup recovery for a legacy config without a recorded tunnel ID", () => { + const { store } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"token"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const registration = yield* registerManagedCloudTunnelRecovery("http://127.0.0.1:3773"); + expect(registration).toEqual({ + status: "recovery_required", + config: { providerKind: "cloudflare_tunnel", connectorToken: "token" }, + }); + expect( + managedTunnelStartupAction({ + wantsCliLink: false, + registration, + }), + ).toEqual({ + action: "request_recovery", + config: { providerKind: "cloudflare_tunnel", connectorToken: "token" }, + }); + expect(requests).toEqual([]); + expect(applyConfigCalls).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("recovers a web-linked tunnel with its environment credential", () => { + const oldConfig = + '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; + const nextConfig = { + providerKind: "cloudflare_tunnel", + connectorToken: "new-token", + tunnelId: "new-tunnel", + } as const; + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* recoverManagedCloudTunnel("http://127.0.0.1:3773")).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe("https://relay.example.test/v1/environments/env_123/tunnel"); + expect(requests[0]?.headers.authorization).toBe("Bearer environment-credential"); + expect(applyConfigCalls).toEqual([nextConfig]); + expect( + Option.getOrNull( + decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))), + ), + ).toEqual(nextConfig); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => + Response.json({ + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: nextConfig, + }), + }), + ); + }); + + it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => + Effect.gen(function* () { + const oldConfig = + '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; + const nextConfig = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "new-token", + tunnelId: "new-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + const requestStarted = yield* Deferred.make(); + const response = yield* Deferred.make(); + const recovery = yield* recoverManagedCloudTunnel("http://127.0.0.1:3773").pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + onRequest: () => Deferred.succeed(requestStarted, undefined), + respondEffect: Deferred.await(response), + }), + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(requestStarted); + expect(requests).toHaveLength(1); + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; + yield* Deferred.succeed( + response, + Response.json({ + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: nextConfig, + }), + ); + + expect(yield* Fiber.join(recovery)).toBe(true); + expect(requests).toHaveLength(1); + expect(applyConfigCalls).toEqual([nextConfig]); + }), + ); + + it.effect("does not recover an environment without a managed tunnel credential", () => { + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* recoverManagedCloudTunnel("http://127.0.0.1:3773")).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("ignores recovery requests for a tunnel that has already been replaced", () => { + const { store } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"current-token","tunnelId":"current-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect( + yield* recoverManagedCloudTunnel("http://127.0.0.1:3773", { + providerKind: "cloudflare_tunnel", + connectorToken: "old-token", + tunnelId: "old-tunnel", + }), + ).toBe(false); + expect(requests).toEqual([]); + expect(applyConfigCalls).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect.each([ + { status: 401, errorTag: "EnvironmentHttpUnauthorizedError" }, + { status: 403, errorTag: "EnvironmentHttpForbiddenError" }, + { status: 409, errorTag: "EnvironmentHttpBadRequestError" }, + ])("preserves a permanent $status relay recovery failure", ({ status, errorTag }) => { + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const error = yield* Effect.flip(recoverManagedCloudTunnel("http://127.0.0.1:3773")); + + expect(error._tag).toBe(errorTag); + expect(requests).toHaveLength(1); + expect(applyConfigCalls).toEqual([]); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => Response.json({}, { status }), + }), + ); + }); + + it.effect("keeps a tunnel configuration replaced during recovery", () => { + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + const freshConfig = new TextEncoder().encode("fresh-config"); + + return Effect.gen(function* () { + expect(yield* recoverManagedCloudTunnel("http://127.0.0.1:3773")).toBe(false); + expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig); + expect(applyConfigCalls).toEqual([]); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => { + values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig); + return Response.json({ + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "replacement-token", + }, + }); + }, + }), + ); + }); }); describe("link proof provider kinds", () => { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index e0d458b4b97c..4943923764aa 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -11,6 +11,7 @@ import { EnvironmentHttpConflictError, EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, + DESKTOP_UPDATE_RESTART_MARKER_FILE, } from "@t3tools/contracts"; import { RelayCloudEnvironmentHealthProofPayload, @@ -28,6 +29,10 @@ import { RelayEnvironmentLinkProofPayload, RelayLinkProofRequest, RelayManagedEndpointOrigin, + RelayManagedEndpointRecoveryProofPayload, + RelayManagedEndpointRecoveryRegistrationResponse, + RelayManagedEndpointRecoveryResponse, + type RelayManagedEndpointRuntimeConfig, RelayOkResponse, } from "@t3tools/contracts/relay"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; @@ -36,12 +41,14 @@ import { RELAY_HEALTH_REQUEST_TYP, RELAY_HEALTH_RESPONSE_TYP, RELAY_LINK_PROOF_TYP, + RELAY_MANAGED_TUNNEL_RECOVERY_TYP, RELAY_MINT_REQUEST_TYP, RELAY_MINT_RESPONSE_TYP, signRelayJwt, verifyRelayJwt, } from "@t3tools/shared/relayJwt"; import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; @@ -50,10 +57,12 @@ 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 Schedule from "effect/Schedule"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpServer from "effect/unstable/http/HttpServer"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -68,9 +77,13 @@ import { } from "./serviceProtocol.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + decodeConfirmedOrigin, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, + decodeRuntimeConfig, encodeEndpointRuntimeConfigJson, + encodeConfirmedOriginJson, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, @@ -85,7 +98,7 @@ import { import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; -import { filterRelayResponse, relayRequestError } from "./relayResponse.ts"; +import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; @@ -93,6 +106,9 @@ const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; +// The desktop app stops its backends within seconds of writing the marker. +const DESKTOP_UPDATE_RESTART_MARKER_TTL = Duration.minutes(1); +const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { "cache-control": "no-store", @@ -297,6 +313,33 @@ function endpointRequestPort(url: URL): number { return Number(url.port || (url.protocol === "https:" ? 443 : 80)); } +export function parseManagedEndpointLocalOrigin(localOrigin: string) { + const url = new URL(localOrigin); + if ( + localOrigin !== localOrigin.trim() || + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" || + localOrigin.includes("?") || + localOrigin.includes("#") + ) { + throw new Error("Invalid local origin"); + } + const wsUrl = new URL(url.origin); + wsUrl.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return { + httpBaseUrl: url.origin, + wsBaseUrl: wsUrl.origin, + origin: { + localHttpHost: url.hostname, + localHttpPort: endpointRequestPort(url), + } satisfies RelayManagedEndpointOrigin, + }; +} + function isAllowedEndpointOrigin(input: { readonly origin: RelayManagedEndpointOrigin; readonly requestUrl: string; @@ -451,55 +494,249 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( ), ); -const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( +function managedEndpointRuntimeConfigsMatch( + left: RelayManagedEndpointRuntimeConfig, + right: RelayManagedEndpointRuntimeConfig, +): boolean { + return ( + left.providerKind === right.providerKind && + left.connectorToken === right.connectorToken && + left.tunnelId === right.tunnelId && + left.tunnelName === right.tunnelName + ); +} + +const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(function* ( dependencies: CloudHttpDependencies, - payload: RelayEnvironmentConfigRequest, + input: { + readonly config: RelayManagedEndpointRuntimeConfig; + readonly configJson: string; + readonly origin: RelayManagedEndpointOrigin; + }, ) { - yield* validateRelayConfigPayload(payload); - yield* validateLinkedCloudUser({ - secrets: dependencies.secrets, - cloudUserId: payload.cloudUserId, + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const currentConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if (Option.isNone(currentConfig) || bytesToString(currentConfig.value) !== input.configJson) { + return null; + } + const status = yield* dependencies.endpointRuntime.applyConfig(input.config); + if (status.status !== "running") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus: status, + }); + } + const marker = yield* encodeConfirmedOriginJson({ + config: input.config, + origin: input.origin, + }); + yield* dependencies.secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); + return status; + }), + ); +}); + +const activateManagedTunnelWithRetry = ( + dependencies: CloudHttpDependencies, + input: { + readonly config: RelayManagedEndpointRuntimeConfig; + readonly configJson: string; + readonly origin: RelayManagedEndpointOrigin; + }, + retryRuntimeFailures: boolean, +) => { + const activate = activateManagedTunnel(dependencies, input); + return retryRuntimeFailures + ? activate.pipe( + Effect.retry({ + while: (error) => + error._tag === "EnvironmentCloudEndpointUnavailableError" && + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( + error.endpointRuntimeStatus, + ), + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ), + }), + ) + : activate; +}; + +export const startManagedCloudTunnelIfOriginConfirmed = Effect.fn( + "environment.cloud.startManagedCloudTunnelIfOriginConfirmed", +)(function* (localOrigin: string, options?: { readonly requireConfirmedOrigin?: boolean }) { + const dependencies = yield* cloudHttpDependencies; + const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), }); - yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); - const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig( - payload.endpointRuntime, + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const [runtimeBytes, markerBytes] = yield* Effect.all([ + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + ]); + if (Option.isNone(runtimeBytes)) return false; + const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeBytes.value))); + if (config === null || config.providerKind !== "cloudflare_tunnel") return false; + // With the marker required, only a config the relay already confirmed on + // this port may start. Without it, startup is falling back after the + // relay stayed unreachable: an unconfirmed origin may send traffic to a + // stale port, but that beats no remote access at all. + if (requireConfirmedOrigin) { + if (Option.isNone(markerBytes)) return false; + const marker = Option.getOrNull(decodeConfirmedOrigin(bytesToString(markerBytes.value))); + if ( + marker === null || + !managedEndpointRuntimeConfigsMatch(marker.config, config) || + marker.origin.localHttpHost !== parsedOrigin.origin.localHttpHost || + marker.origin.localHttpPort !== parsedOrigin.origin.localHttpPort + ) { + return false; + } + } + const status = yield* dependencies.endpointRuntime.applyConfig(config); + if (status.status !== "running") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus: status, + }); + } + return true; + }), ); - const ok = - endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; - if (!ok) { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", - endpointRuntimeStatus, +}); + +const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( + dependencies: CloudHttpDependencies, + payload: RelayEnvironmentConfigRequest, + options?: { + readonly lockHeld?: boolean; + readonly confirmedOrigin?: RelayManagedEndpointOrigin; + }, +) { + const apply = Effect.gen(function* () { + yield* validateRelayConfigPayload(payload); + yield* validateLinkedCloudUser({ + secrets: dependencies.secrets, + cloudUserId: payload.cloudUserId, }); - } + yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); + // Reject unsupported runtimes before touching the connector so a bad + // payload cannot stop a healthy tunnel on its way to a 503. + if ( + payload.endpointRuntime !== null && + payload.endpointRuntime.providerKind !== "cloudflare_tunnel" + ) { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus: { + status: "unsupported", + providerKind: payload.endpointRuntime.providerKind, + }, + }); + } + yield* dependencies.endpointRuntime.applyConfig(null); + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); - yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); - yield* dependencies.secrets.set( - RELAY_ISSUER_SECRET, - stringToBytes(payload.relayIssuer ?? payload.relayUrl), - ); - yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); - yield* dependencies.secrets.set( - RELAY_ENVIRONMENT_CREDENTIAL_SECRET, - stringToBytes(payload.environmentCredential), - ); - yield* dependencies.secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); - if (payload.endpointRuntime) { - const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); yield* dependencies.secrets.set( - CLOUD_ENDPOINT_RUNTIME_CONFIG, - stringToBytes(endpointRuntimeJson), + RELAY_ISSUER_SECRET, + stringToBytes(payload.relayIssuer ?? payload.relayUrl), ); - } else { - yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); - } - return { ok, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); + yield* dependencies.secrets.set( + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + stringToBytes(payload.environmentCredential), + ); + yield* dependencies.secrets.set( + CLOUD_MINT_PUBLIC_KEY, + stringToBytes(payload.cloudMintPublicKey), + ); + if (payload.endpointRuntime) { + const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + yield* dependencies.secrets.set( + CLOUD_ENDPOINT_RUNTIME_CONFIG, + stringToBytes(endpointRuntimeJson), + ); + } else { + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) { + return { + ok: true, + endpointRuntimeStatus: { status: "disabled" }, + } satisfies EnvironmentCloudRelayConfigResult; + } + const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig( + payload.endpointRuntime, + ); + if (endpointRuntimeStatus.status !== "running") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus, + }); + } + const marker = yield* encodeConfirmedOriginJson({ + config: payload.endpointRuntime, + origin: options.confirmedOrigin, + }); + yield* dependencies.secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }); + return yield* options?.lockHeld ? apply : dependencies.endpointRuntime.withLinkStateLock(apply); }); const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { yield* requireEnvironmentScope(AuthRelayWriteScope); - return yield* applyCloudRelayConfig(dependencies, payload); + const result = yield* applyCloudRelayConfig(dependencies, payload); + if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* new EnvironmentHttpInternalServerError({ + message: "Could not resolve the local server origin.", + }); + } + const registration = yield* registerManagedCloudTunnelRecovery( + `http://127.0.0.1:${address.port}`, + ).pipe( + Effect.retry({ + times: 2, + while: (error) => + shouldRetryCloudLink(error) && + error._tag !== "EnvironmentCloudEndpointUnavailableError", + }), + ); + if (registration.status === "superseded") { + return yield* new EnvironmentHttpConflictError({ + message: "The managed tunnel configuration changed during registration.", + }); + } + if (registration.status === "recovery_required") { + yield* dependencies.endpointRuntime.requestRecovery(registration.config); + } + if (registration.status !== "ready") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint origin could not be confirmed.", + endpointRuntimeStatus: { status: "disabled" }, + }); + } + return { + ok: true, + endpointRuntimeStatus: registration.endpointRuntimeStatus, + } satisfies EnvironmentCloudRelayConfigResult; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentCloudInternalError(error.message)(error), @@ -508,10 +745,14 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment relay configuration."), ), - Effect.catchTag( - "SchemaError", - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), + Effect.catchTags({ + SchemaError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + PlatformError: failEnvironmentCloudInternalError( + "Could not register the managed endpoint origin.", + ), + }), ); const relayClientRequest = ( @@ -521,6 +762,7 @@ const relayClientRequest = ( readonly token: string; readonly payload: unknown; readonly schema: Schema.Decoder; + readonly timeout?: Duration.Input; }, ) => HttpClientRequest.post(input.url).pipe( @@ -529,25 +771,20 @@ const relayClientRequest = ( Effect.flatMap(dependencies.httpClient.execute), Effect.flatMap(filterRelayResponse), Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), + Effect.timeout(input.timeout ?? "10 seconds"), Effect.mapError(relayRequestError), withRelayClientTracing, ); const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( function* (dependencies: CloudHttpDependencies, localOrigin: string) { - const localUrl = yield* Effect.try({ - try: () => new URL(localOrigin), + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), catch: () => new EnvironmentHttpBadRequestError({ message: "Could not resolve local environment origin.", }), }); - if (localUrl.origin !== localOrigin) { - return yield* new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", - }); - } - const localWsOrigin = localOrigin.replace(/^http/u, "ws"); const token = yield* dependencies.cliTokenManager.getExisting.pipe( Effect.flatMap( Option.match({ @@ -580,16 +817,13 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi challenge: challenge.challenge, relayIssuer: relayUrl, endpoint: { - httpBaseUrl: localOrigin, - wsBaseUrl: localWsOrigin, + httpBaseUrl: parsedOrigin.httpBaseUrl, + wsBaseUrl: parsedOrigin.wsBaseUrl, providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual", }, - origin: { - localHttpHost: localUrl.hostname, - localHttpPort: endpointRequestPort(localUrl), - }, + origin: parsedOrigin.origin, }, - localOrigin, + parsedOrigin.httpBaseUrl, ); const link = yield* relayClientRequest(dependencies, { url: `${relayUrl}/v1/client/environment-links`, @@ -601,16 +835,27 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi managedTunnelsEnabled, }, schema: RelayEnvironmentLinkResponse, + timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, }); yield* setCliDesiredCloudLink(true, mode); - return yield* applyCloudRelayConfig(dependencies, { - relayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }); + yield* applyCloudRelayConfig( + dependencies, + { + relayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + { + lockHeld: true, + confirmedOrigin: parsedOrigin.origin, + }, + ); + // Callers decide on managed tunnel recovery from the mode this link + // actually used, not from a value read before the relay round trip. + return mode; }, Effect.catchIf( ServerSecretStore.isSecretStoreError, @@ -627,7 +872,260 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileDesiredLink")( function* (localOrigin: string) { - return yield* reconcileDesiredCloudLinkWith(yield* cloudHttpDependencies, localOrigin); + const dependencies = yield* cloudHttpDependencies; + return yield* dependencies.endpointRuntime.withLinkStateLock( + reconcileDesiredCloudLinkWith(dependencies, localOrigin), + ); + }, +); + +export const reconcileDesiredCloudLinkIfStillDesired = Effect.fn( + "environment.cloud.reconcileDesiredLinkIfStillDesired", +)(function* (localOrigin: string) { + const dependencies = yield* cloudHttpDependencies; + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + if (!(yield* readCliDesiredCloudLink)) { + return null; + } + return yield* reconcileDesiredCloudLinkWith(dependencies, localOrigin); + }), + ); +}); + +type ManagedTunnelRecoveryProofInput = { + readonly environmentId: RelayManagedEndpointRecoveryProofPayload["environmentId"]; + readonly cloudUserId: string; + readonly relayUrl: string; +} & ( + | { + readonly action: "register"; + readonly tunnelId: string; + readonly origin: RelayManagedEndpointOrigin; + } + | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } +); + +const makeManagedTunnelRecoveryProof = Effect.fn( + "environment.cloud.makeManagedTunnelRecoveryProof", +)(function* (dependencies: CloudHttpDependencies, input: ManagedTunnelRecoveryProofInput) { + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + const configuredIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET); + const now = yield* DateTime.now; + const issuedAt = Math.floor(now.epochMilliseconds / 1_000); + const claims = { + iss: `t3-env:${input.environmentId}`, + aud: normalizeRelayIssuer( + Option.isSome(configuredIssuer) ? bytesToString(configuredIssuer.value) : input.relayUrl, + ), + sub: input.environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: issuedAt, + exp: issuedAt + 60, + environmentId: input.environmentId, + cloudUserId: input.cloudUserId, + }; + const payload = + input.action === "register" + ? { + ...claims, + action: "register" as const, + tunnelId: input.tunnelId, + origin: input.origin, + } + : { ...claims, action: "recover" as const, origin: input.origin }; + + return yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP, + payload, + }).pipe( + Effect.mapError( + () => + new EnvironmentHttpInternalServerError({ + message: "Could not sign the managed tunnel recovery request.", + }), + ), + ); +}); + +export const registerManagedCloudTunnelRecovery = Effect.fn( + "environment.cloud.registerManagedCloudTunnelRecovery", +)(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { + const dependencies = yield* cloudHttpDependencies; + const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if ( + Option.isNone(runtimeConfig) || + Option.isNone(relayUrl) || + Option.isNone(cloudUserId) || + Option.isNone(environmentCredential) + ) { + return { status: "not_linked" as const }; + } + + const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); + if (config?.providerKind !== "cloudflare_tunnel") { + return { status: "not_linked" as const }; + } + + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), + }); + if (config.tunnelId === undefined) { + return { status: "recovery_required" as const, config }; + } + const origin = parsedOrigin.origin; + const environmentId = yield* dependencies.environment.getEnvironmentId; + const relayUrlValue = bytesToString(relayUrl.value); + const cloudUserIdValue = bytesToString(cloudUserId.value); + const proof = yield* makeManagedTunnelRecoveryProof(dependencies, { + action: "register", + environmentId, + cloudUserId: cloudUserIdValue, + relayUrl: relayUrlValue, + tunnelId: config.tunnelId, + origin, + }); + const registered = yield* relayClientRequest(dependencies, { + url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, + token: bytesToString(environmentCredential.value), + payload: { + cloudUserId: cloudUserIdValue, + tunnelId: config.tunnelId, + origin, + proof, + }, + schema: RelayManagedEndpointRecoveryRegistrationResponse, + }); + if (registered.status === "recovery_required") { + return { status: registered.status, config }; + } + const endpointRuntimeStatus = yield* activateManagedTunnelWithRetry( + dependencies, + { + config, + configJson: bytesToString(runtimeConfig.value), + origin, + }, + options?.retryRuntimeFailures === true, + ); + return endpointRuntimeStatus === null + ? { status: "superseded" as const } + : { status: "ready" as const, endpointRuntimeStatus }; +}); + +export const recoverManagedCloudTunnel = Effect.fn("environment.cloud.recoverManagedCloudTunnel")( + function* ( + localOrigin: string, + expectedConfig?: RelayManagedEndpointRuntimeConfig, + options?: { readonly retryRuntimeFailures?: boolean }, + ) { + const dependencies = yield* cloudHttpDependencies; + const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if ( + Option.isNone(runtimeConfig) || + Option.isNone(relayUrl) || + Option.isNone(cloudUserId) || + Option.isNone(environmentCredential) + ) { + return false; + } + if (expectedConfig !== undefined) { + const current = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); + if ( + current === null || + current.providerKind !== expectedConfig.providerKind || + current.connectorToken !== expectedConfig.connectorToken || + current.tunnelId !== expectedConfig.tunnelId || + current.tunnelName !== expectedConfig.tunnelName + ) { + return false; + } + } + + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), + }); + + const environmentId = yield* dependencies.environment.getEnvironmentId; + const relayUrlValue = bytesToString(relayUrl.value); + const cloudUserIdValue = bytesToString(cloudUserId.value); + const origin = parsedOrigin.origin; + const proof = yield* makeManagedTunnelRecoveryProof(dependencies, { + action: "recover", + environmentId, + cloudUserId: cloudUserIdValue, + relayUrl: relayUrlValue, + origin, + }); + const recovered = yield* relayClientRequest(dependencies, { + url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, + token: bytesToString(environmentCredential.value), + payload: { + cloudUserId: cloudUserIdValue, + origin, + proof, + }, + schema: RelayManagedEndpointRecoveryResponse, + timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, + }); + if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { + return yield* new EnvironmentHttpInternalServerError({ + message: "T3 Connect returned an unsupported managed tunnel configuration.", + }); + } + + const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( + Effect.mapError( + () => + new EnvironmentHttpInternalServerError({ + message: "Could not persist the recovered managed tunnel configuration.", + }), + ), + ); + const stored = yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const currentConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if ( + Option.isNone(currentConfig) || + bytesToString(currentConfig.value) !== bytesToString(runtimeConfig.value) + ) { + return false; + } + yield* dependencies.secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + return true; + }), + ); + if (!stored) return false; + const status = yield* activateManagedTunnelWithRetry( + dependencies, + { + config: recovered.endpointRuntime, + configJson: encoded, + origin, + }, + options?.retryRuntimeFailures === true, + ); + return status !== null; }, ); @@ -663,6 +1161,29 @@ const pendingUpdateHandoffExists = Effect.gen(function* () { return !stopping; }); +// The desktop app writes its marker right before it stops this server to +// install an update, whether a remote client or the local app started it. +// Reading consumes it, so shutdown checks it first. Only a fresh marker counts, +// so a marker the server never read (a hard kill) cannot keep the tunnel on a +// later quit. +const desktopUpdateRestartPending = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const markerPath = path.join(config.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE); + const marker = yield* fs.stat(markerPath).pipe(Effect.option); + if (Option.isNone(marker)) { + return false; + } + yield* fs.remove(markerPath).pipe(Effect.ignore); + const now = yield* Clock.currentTimeMillis; + return Option.match(marker.value.mtime, { + onNone: () => false, + onSome: (writtenAt) => + now - writtenAt.getTime() < Duration.toMillis(DESKTOP_UPDATE_RESTART_MARKER_TTL), + }); +}); + // Cloudflare bills per provisioned tunnel, so an environment that goes offline // must not leave its tunnel behind. Releasing deletes only the tunnel — the // relay keeps the link and its hostname reservation, and the next startup's @@ -677,24 +1198,23 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( if (Option.isNone(runtimeConfig)) { return false; } - // Only CLI-desired managed links release on shutdown, because the startup - // reconcile that provisions the replacement tunnel only runs for them. A - // link installed by a web/mobile client comes back after a restart by - // reapplying the stored connector token — it has no boot-time re-provision - // path — so its tunnel must survive the restart. (Unlink still deletes it.) + // Only CLI-desired managed links release eagerly because this request uses + // CLI authorization. Web/mobile links register startup recovery with their + // environment credential, and the relay reaper removes them after they are + // down for the configured grace period. Unlink still deletes either kind. if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") { return false; } - // A shutdown that hands off to a pending remote update is not the - // environment going offline: the launcher immediately brings a server back - // (the new version, or the old one after a rollback). Deleting the tunnel - // here forces that server to provision a replacement UUID, and the public - // hostname's route to the new tunnel takes 1-2 minutes to propagate — the - // dominant cost of an update restart. Keep the tunnel instead: the next + // A shutdown that hands off to a pending update is not the environment + // going offline: the service launcher or the desktop app immediately brings + // a server back (the new version, or the old one after a rollback). Deleting + // the tunnel here forces that server to provision a replacement UUID, and the + // public hostname's route to the new tunnel takes 1-2 minutes to propagate — + // the dominant cost of an update restart. Keep the tunnel instead: the next // boot respawns the connector from the stored config and is reachable as // soon as it connects, and the reconcile confirms the still-live tunnel // without replacing it. - if (yield* pendingUpdateHandoffExists) { + if ((yield* desktopUpdateRestartPending) || (yield* pendingUpdateHandoffExists)) { yield* Effect.logInfo("Keeping the managed tunnel across the update restart"); return false; } @@ -738,6 +1258,7 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value) ) { yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); } return true; }); @@ -784,21 +1305,26 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( function* (dependencies: CloudHttpDependencies) { yield* requireEnvironmentScope(AuthRelayWriteScope); - const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); - yield* Effect.all( - [ - dependencies.secrets.remove(CLOUD_LINKED_USER_ID), - dependencies.secrets.remove(RELAY_URL_SECRET), - dependencies.secrets.remove(RELAY_ISSUER_SECRET), - dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), - dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY), - dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), - dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), - ], - { concurrency: 7 }, + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); + yield* Effect.all( + [ + dependencies.secrets.remove(CLOUD_LINKED_USER_ID), + dependencies.secrets.remove(RELAY_URL_SECRET), + dependencies.secrets.remove(RELAY_ISSUER_SECRET), + dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY), + dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + ], + { concurrency: 8 }, + ); + yield* setCliDesiredCloudLink(false); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }), ); - yield* setCliDesiredCloudLink(false); - return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, Effect.catchIf( ServerSecretStore.isSecretStoreError, diff --git a/apps/server/src/cloud/managedTunnelStartup.test.ts b/apps/server/src/cloud/managedTunnelStartup.test.ts new file mode 100644 index 000000000000..1d75f606cd2f --- /dev/null +++ b/apps/server/src/cloud/managedTunnelStartup.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { + managedTunnelStartupAction, + retryManagedTunnelRegistration, +} from "./managedTunnelStartup.ts"; + +describe("managedTunnelStartupAction", () => { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }; + + it("requests tunnel recovery only when the relay proves it is needed", () => { + expect( + managedTunnelStartupAction({ + wantsCliLink: true, + registration: { status: "recovery_required", config }, + }), + ).toEqual({ action: "request_recovery", config }); + }); + + it("creates a desired CLI link only when no local managed link exists", () => { + expect( + managedTunnelStartupAction({ + wantsCliLink: true, + registration: { status: "not_linked" }, + }), + ).toEqual({ action: "reconcile_link" }); + }); + + it.each(["ready", "unavailable"] as const)( + "does not provision after a %s registration result", + (status) => { + expect( + managedTunnelStartupAction({ + wantsCliLink: true, + registration: { status }, + }), + ).toEqual({ action: "none" }); + }, + ); +}); + +describe("retryManagedTunnelRegistration", () => { + it.effect("does not fall back or retry when registration is permanently rejected", () => + Effect.gen(function* () { + let attempts = 0; + let fallbacks = 0; + const error = yield* Effect.flip( + retryManagedTunnelRegistration( + Effect.suspend(() => { + attempts += 1; + return Effect.fail("not authorized"); + }), + () => false, + Effect.sync(() => { + fallbacks += 1; + }), + ), + ); + expect(error).toBe("not authorized"); + expect(attempts).toBe(1); + expect(fallbacks).toBe(0); + }), + ); + + it.effect("stops retrying after the retry window so startup can fall back", () => + Effect.gen(function* () { + let attempts = 0; + const registration = Effect.suspend(() => { + attempts += 1; + return Effect.fail("relay unavailable" as const); + }); + const fiber = yield* Effect.forkChild( + Effect.flip(retryManagedTunnelRegistration(registration, () => true)), + { startImmediately: true }, + ); + yield* TestClock.adjust("15 minutes"); + expect(yield* Fiber.join(fiber)).toBe("relay unavailable"); + // Capped at 30 seconds between attempts, ten minutes allows a bounded run. + expect(attempts).toBeGreaterThan(5); + expect(attempts).toBeLessThan(60); + }), + ); + + it.effect("waits for successful registration before it activates the connector", () => + Effect.gen(function* () { + const firstAttempt = yield* Deferred.make(); + let attempts = 0; + let activations = 0; + let reconciliations = 0; + const registration = Effect.suspend(() => { + attempts += 1; + if (attempts === 1) { + return Deferred.succeed(firstAttempt, undefined).pipe( + Effect.andThen(Effect.fail("relay unavailable" as const)), + ); + } + return Effect.succeed({ status: "ready" as const }); + }); + const startup = retryManagedTunnelRegistration(registration, () => true).pipe( + Effect.tap((result) => + Effect.sync(() => { + const action = managedTunnelStartupAction({ wantsCliLink: true, registration: result }); + if (action.action === "reconcile_link") { + reconciliations += 1; + } + activations += 1; + }), + ), + ); + + const fiber = yield* Effect.forkChild(startup, { startImmediately: true }); + yield* Deferred.await(firstAttempt); + expect(attempts).toBe(1); + expect(activations).toBe(0); + + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(fiber); + + expect(attempts).toBe(2); + expect(activations).toBe(1); + expect(reconciliations).toBe(0); + }), + ); +}); diff --git a/apps/server/src/cloud/managedTunnelStartup.ts b/apps/server/src/cloud/managedTunnelStartup.ts new file mode 100644 index 000000000000..fe666cd0f24b --- /dev/null +++ b/apps/server/src/cloud/managedTunnelStartup.ts @@ -0,0 +1,77 @@ +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +export type ManagedTunnelRegistrationResult = + | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" } + | { + readonly status: "recovery_required"; + readonly config: RelayManagedEndpointRuntimeConfig; + }; + +export type ManagedTunnelStartupAction = + | { readonly action: "none" } + | { readonly action: "reconcile_link" } + | { + readonly action: "request_recovery"; + readonly config: RelayManagedEndpointRuntimeConfig; + }; + +export function managedTunnelStartupAction(input: { + readonly wantsCliLink: boolean; + readonly registration: ManagedTunnelRegistrationResult; +}): ManagedTunnelStartupAction { + if (input.registration.status === "recovery_required") { + return { + action: "request_recovery", + config: input.registration.config, + }; + } + if (input.wantsCliLink && input.registration.status === "not_linked") { + return { action: "reconcile_link" }; + } + return { action: "none" }; +} + +// After this window the host can start its stored connector config while +// registration keeps retrying to reconcile the origin when the relay returns. +const MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW = Duration.minutes(10); + +export const retryManagedTunnelRegistration = ( + registration: Effect.Effect, + isRetryable: (error: E) => boolean, + onRetryWindowExhausted?: Effect.Effect, +) => { + const schedule = Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ); + return registration.pipe( + Effect.retry({ + while: isRetryable, + schedule: schedule.pipe( + Schedule.upTo({ duration: MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW }), + ), + }), + Effect.catch((error) => + onRetryWindowExhausted !== undefined && isRetryable(error) + ? onRetryWindowExhausted.pipe( + Effect.andThen(registration.pipe(Effect.retry({ while: isRetryable, schedule }))), + ) + : Effect.fail(error), + ), + ); +}; + +// A host asks the relay for a replacement tunnel at most this often. Every +// managed host shares one relay, so a host stuck in a bad loop must not turn +// into a fleet-wide request storm. +export const MANAGED_TUNNEL_RECOVERY_COOLDOWN = Duration.minutes(2); + +// Existing hosts register on their first boot after an upgrade, and desktop +// auto-update delivers that boot to many hosts at once. Spread the first +// registration so the relay and Cloudflare see a ramp instead of a spike. +export const MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER = Duration.seconds(30); diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 0f039274e207..73884bb6f9e5 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -139,3 +139,29 @@ it.each([ expect(await response.text()).not.toContain("private credential diagnostic"); expect(requests).toEqual([]); }); + +it.each([1, 3])( + "forwards fixed Duo display %s through the authenticated read proxy", + async (panel) => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const route = `/vendor/serve-sim/helper/duo/panel/${panel}/stream.avcc`; + const response = await handler( + new Request(`http://t3.test/api/device-hub${route}?wsTicket=secret`), + ); + expect(response.status).toBe(200); + await response.text(); + expect(requests).toEqual([`http://hub.test${route}`]); + }, +); + +it.each(["/panel/2/stream.avcc", "/panel/1/webrtc/offer", "/panel/3/exec"])( + "rejects unsupported Duo route %s", + async (route) => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const response = await handler( + new Request(`http://t3.test/api/device-hub/vendor/serve-sim/helper/duo${route}`), + ); + expect(response.status).toBe(404); + expect(requests).toEqual([]); + }, +); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index dd048480b3dd..0fb49c68e281 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -42,6 +42,7 @@ const ALLOWED_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/api\/screenshot$/, /^\/vendor\/serve-sim\/api\/event-log(\/events)?$/, /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/, + /^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/, /^\/vendor\/serve-sim\/appstate$/, /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/, /^\/vendor\/serve-emu\/health$/, diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index e8e7d5cae46a..f30960ed2212 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -26,7 +26,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -export const DEVICE_HUB_VERSION = "0.10.1"; +export const DEVICE_HUB_VERSION = "0.12.0"; const AGENT_DEVICE_PACKAGE = "agent-device"; export const AGENT_DEVICE_VERSION = "0.21.12"; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a36c0a03b6f2..17fe73cc326f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -236,6 +236,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadActiveReorder: true, + threadAutoSettleOptOut: true, threadTitleRegeneration: true, threadPullRequests: true, pullRequestStackActions: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b5e6cb0cdd54..4163168157e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -633,6 +633,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pinnedAt: null, pinOrderKey: null, activeOrderKey: null, + autoSettleDisabledAt: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -782,6 +783,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.auto-settle-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + autoSettleDisabledAt: event.payload.autoSettleDisabledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.pin-reordered": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 843eb8343d84..890c8ae55c53 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -485,6 +485,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, deletedAt: null, @@ -611,6 +612,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, session: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1e7058742e25..1b44054c7a32 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -587,6 +587,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -628,6 +629,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -701,6 +703,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1266,6 +1269,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -2341,6 +2345,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -2586,6 +2591,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -2742,6 +2748,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -2905,6 +2912,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -3261,6 +3269,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, + autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, @@ -3562,6 +3571,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, + autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, deletedAt: null, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d61739f72c21..9b5b56309749 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -5174,4 +5174,59 @@ describe("splitBufferedAssistantText", () => { rest: "```\n- one\n- two\n", }); }); + + it("holds a heading until the block under it is done", () => { + expect(splitBufferedAssistantText("intro\n\n## Setup\n\nInstall it")).toEqual({ + ready: "intro\n\n", + rest: "## Setup\n\nInstall it", + }); + expect( + splitBufferedAssistantText("intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\nNext"), + ).toEqual({ + ready: "intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\n", + rest: "Next", + }); + }); + + it("delivers the paragraph above a heading with no blank line between them", () => { + expect(splitBufferedAssistantText("para\n## Setup\n\nInstall")).toEqual({ + ready: "para\n", + rest: "## Setup\n\nInstall", + }); + // A bold line there continues the paragraph, so both stay buffered. + expect(splitBufferedAssistantText("para\n**Setup**\n\nInstall")).toEqual({ + ready: "", + rest: "para\n**Setup**\n\nInstall", + }); + }); + + it("holds a line of only bold text like a heading", () => { + expect(splitBufferedAssistantText("**Risk by area:**\n\n| a |\n|---|\n")).toEqual({ + ready: "", + rest: "**Risk by area:**\n\n| a |\n|---|\n", + }); + expect(splitBufferedAssistantText("**Use *npm* now**\n\nInstall it")).toEqual({ + ready: "", + rest: "**Use *npm* now**\n\nInstall it", + }); + expect(splitBufferedAssistantText("**Note:** read this.\n\nNext")).toEqual({ + ready: "**Note:** read this.\n\n", + rest: "Next", + }); + }); + + it("delivers a held heading with its first list item or its whole code block", () => { + expect(splitBufferedAssistantText("## Steps\n\n- one\n- tw")).toEqual({ + ready: "## Steps\n\n- one\n", + rest: "- tw", + }); + expect(splitBufferedAssistantText("## Code\n\n```ts\na\n\nb\n")).toEqual({ + ready: "", + rest: "## Code\n\n```ts\na\n\nb\n", + }); + expect(splitBufferedAssistantText("## Code\n\n```ts\na\n```\nafter")).toEqual({ + ready: "## Code\n\n```ts\na\n```\n", + rest: "after", + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0db70e491235..d26930637db1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/; // nested items count. The trailing space is required, so a partial `-` or // `1.` never matches before the model finishes the marker. const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; +// A section title: an ATX heading, or a line of only bold text, which models +// often use as a heading. +const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/; +// An unindented ATX heading ends the paragraph or list above it, even with no +// blank line between them. A bold line would continue the paragraph instead. +const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; /** * Splits buffered assistant text at the last blank line, closing code fence, @@ -221,17 +227,26 @@ const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; * never leaks; a list item start is the one lookahead that may sit on the * partial line, since tight lists have no blank lines between items and would * otherwise land all at once. + * + * A section title holds the boundary until a content line follows it, so a + * title never lands alone and waits above a block that is still streaming. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { let openFence: { marker: string; indent: number } | null = null; let boundary = -1; let lineStart = 0; + let titleAwaitingContent = false; for (;;) { const newline = text.indexOf("\n", lineStart); const line = text .slice(lineStart, newline === -1 ? text.length : newline) .replace(/[ \t\r]+$/, ""); - if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { + if ( + openFence === null && + lineStart > 0 && + !titleAwaitingContent && + LIST_ITEM_START_PATTERN.test(line) + ) { boundary = lineStart; } if (newline === -1) { @@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: const marker = fenceMatch[2]!; if (openFence === null) { openFence = { marker, indent }; + titleAwaitingContent = false; } else if ( marker[0] === openFence.marker[0] && marker.length >= openFence.marker.length && @@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: boundary = newline + 1; } } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { - boundary = newline + 1; + if (!titleAwaitingContent) { + boundary = newline + 1; + } + } else if (openFence === null) { + if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) { + boundary = lineStart; + } + titleAwaitingContent = SECTION_TITLE_PATTERN.test(line); } lineStart = newline + 1; } diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 29468dc3f84e..f4fe2e0104f6 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,7 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadAutoSettleSetPayload as ContractsThreadAutoSettleSetPayloadSchema, ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, @@ -51,6 +52,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadAutoSettleSetPayload = ContractsThreadAutoSettleSetPayloadSchema; export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 252b99439400..8a1588b18752 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => { it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + }); + + it("never settles a thread whose auto-settle is turned off, by inactivity or merge", () => { + const held = makeThread({ autoSettleDisabledAt: "2026-08-21T00:00:00.000Z" }); + expect(decide(held)).toBe(false); + expect( + decide(held, { state: "merged", mergedAt: "2026-08-21T00:00:00.000Z", closedAt: null }), + ).toBe(false); + expect(decide(makeThread({ autoSettleDisabledAt: null }))).toBe(true); expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); @@ -248,6 +257,21 @@ const terminalSnapshot = ( syncedAt: NOW, }); +describe("per-thread auto-settle opt out", () => { + it("blocks both inactivity and merge settlement while auto-settle is off", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + expect(decide(makeThread({ latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe(true); + expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null })).toBe(true); + const held = { autoSettleDisabledAt: NOW }; + expect(decide(makeThread({ ...held, latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe( + false, + ); + expect(decide(makeThread({ ...held, pullRequests: [merged] }), null, { days: null })).toBe( + false, + ); + }); +}); + describe("linked request settlement", () => { it.each(["closed", "merged"] as const)( "uses the latest actual %s transition despite later comments on another PR", diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 92063745eff5..113d68204e59 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -117,6 +117,7 @@ export function resolveAutoSettlementAt(input: { /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.autoSettleDisabledAt != null) return false; if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.session?.status === "starting" || thread.session?.status === "running") return false; if (thread.backgroundLiveness != null) return false; diff --git a/apps/server/src/orchestration/decider.autoSettleSet.test.ts b/apps/server/src/orchestration/decider.autoSettleSet.test.ts new file mode 100644 index 000000000000..99657069cc74 --- /dev/null +++ b/apps/server/src/orchestration/decider.autoSettleSet.test.ts @@ -0,0 +1,154 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const DISABLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly autoSettleDisabledAt?: string | null; + readonly settledOverride?: "settled" | "active" | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, + autoSettleDisabledAt: input.autoSettleDisabledAt ?? null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const events = (event: Effect.Success>) => + Array.isArray(event) ? event : [event]; + +it.layer(NodeServices.layer)("thread.auto-settle.set decider", (it) => { + it.effect("turning auto-settle off stamps autoSettleDisabledAt and updatedAt together", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-off"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: makeReadModel({}), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBe(event.payload.updatedAt); + expect(event.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("turning it off again keeps the original stamp and updatedAt", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-off-again"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBe(DISABLED_AT); + expect(event.payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("turning auto-settle back on clears the stamp", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-on"), + threadId: ThreadId.make("thread-1"), + enabled: true, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBeNull(); + expect(event.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("automatic settlement is rejected while auto-settle is off", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + settledAt: NOW, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("a manual settle still works while auto-settle is off", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-manual"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.settled"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0119c0e8599a..b610f3868525 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -484,7 +484,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { + if ( + command.type === "thread.auto-settle" && + (thread.settledOverride !== null || thread.autoSettleDisabledAt != null) + ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -860,6 +863,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.auto-settle.set": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.unpin): setting the current + // state again keeps the existing timestamps so duplicates do not churn + // ordering. The flag is independent of the settled lifecycle: it only + // gates the automatic paths, so it never blocks a manual settle. + const currentlyDisabledAt = thread.autoSettleDisabledAt ?? null; + const unchanged = command.enabled + ? currentlyDisabledAt === null + : currentlyDisabledAt !== null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.auto-settle-set", + payload: { + threadId: command.threadId, + autoSettleDisabledAt: command.enabled ? null : (currentlyDisabledAt ?? occurredAt), + updatedAt: unchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.active.reorder": { const thread = yield* requireThreadNotArchived({ readModel, diff --git a/apps/server/src/orchestration/projector.autoSettleSet.test.ts b/apps/server/src/orchestration/projector.autoSettleSet.test.ts new file mode 100644 index 000000000000..cc12f6905910 --- /dev/null +++ b/apps/server/src/orchestration/projector.autoSettleSet.test.ts @@ -0,0 +1,105 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects auto-settle opt-out and survives a manual settle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-02T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.autoSettleDisabledAt ?? null).toBeNull(); + + const disabled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.auto-settle-set", + payload: { threadId: ThreadId.make("thread-1"), autoSettleDisabledAt: now, updatedAt: now }, + }), + ); + expect(disabled.threads[0]?.autoSettleDisabledAt).toBe(now); + + // The flag is independent of the settled lifecycle: settling by hand and + // un-settling later must not clear it. + const settled = yield* projectEvent( + disabled, + makeEvent({ + sequence: 3, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: later, updatedAt: later }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.autoSettleDisabledAt).toBe(now); + + const unsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: later }, + }), + ); + expect(unsettled.threads[0]?.autoSettleDisabledAt).toBe(now); + + const enabled = yield* projectEvent( + unsettled, + makeEvent({ + sequence: 5, + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: null, + updatedAt: later, + }, + }), + ); + expect(enabled.threads[0]?.autoSettleDisabledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..516b85fbdae8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -93,6 +93,7 @@ describe("orchestration projector", () => { updatedAt: now, archivedAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, settledOverride: null, settledAt: null, unsettledAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..85d9db3fdfed 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -42,6 +42,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAutoSettleSetPayload, ThreadPullRequestLinkedPayload, ThreadPullRequestSyncedPayload, ThreadPullRequestUnlinkedPayload, @@ -440,6 +441,7 @@ export function projectEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -580,6 +582,17 @@ export function projectEvent( })), ); + case "thread.auto-settle-set": + return decodeForEvent(ThreadAutoSettleSetPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + autoSettleDisabledAt: payload.autoSettleDisabledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.pin-reordered": return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index af36578f286e..595bc0b5594c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at, pin_order_key, active_order_key, + auto_settle_disabled_at, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -86,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pinnedAt}, ${row.pinOrderKey ?? null}, ${row.activeOrderKey ?? null}, + ${row.autoSettleDisabledAt ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -118,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at = excluded.pinned_at, pin_order_key = excluded.pin_order_key, active_order_key = excluded.active_order_key, + auto_settle_disabled_at = excluded.auto_settle_disabled_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -157,6 +160,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 18aae09febf8..c837ae3f4c50 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; +import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -130,6 +131,7 @@ const migrationEntries = [ [51, "ProjectionThreadMessageContext", Migration0051], [52, "ProjectionThreadTitleState", Migration0052], [53, "PullRequestFilesViewed", Migration0053], + [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts new file mode 100644 index 000000000000..df6403316e5b --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts @@ -0,0 +1,41 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; +import migrateAutoSettleDisabledAt from "./054_ProjectionThreadsAutoSettleDisabledAt.ts"; + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))( + "054_ProjectionThreadsAutoSettleDisabledAt", + (it) => { + it.effect("adds the column with auto-settle left on for existing threads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 53 }); + const now = "2026-01-01T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + created_at, updated_at + ) VALUES ( + 'thread-1', 'project-1', 'Existing thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} + ) + `; + yield* runMigrations({ toMigrationInclusive: 54 }); + const migrated = yield* sql<{ readonly autoSettleDisabledAt: string | null }>` + SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(migrated, [{ autoSettleDisabledAt: null }]); + // Re-running against a database that already has the column keeps its value. + yield* sql`UPDATE projection_threads SET auto_settle_disabled_at = ${now} WHERE thread_id = 'thread-1'`; + yield* migrateAutoSettleDisabledAt; + const rows = yield* sql<{ readonly autoSettleDisabledAt: string | null }>` + SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ autoSettleDisabledAt: now }]); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts new file mode 100644 index 000000000000..f91f6d8abdfa --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts @@ -0,0 +1,15 @@ +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_threads) + `; + if (!columns.some((column) => column.name === "auto_settle_disabled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN auto_settle_disabled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 895fe596db24..abd964382a09 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -50,6 +50,7 @@ export const ProjectionThread = Schema.Struct({ pinnedAt: Schema.NullOr(IsoDateTime), pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), + autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 5ebb41a1bb54..bf9eb702ebe5 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -87,6 +87,7 @@ function hasImportBlockingActivity( thread.snoozedAt != null || thread.pinnedAt != null || thread.pinOrderKey != null || + thread.autoSettleDisabledAt != null || thread.titleRegeneration != null || thread.linkedPullRequest != null || thread.unsettledAt != null || diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e2bab831712a..de72ebcc4e23 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -342,6 +342,8 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { if (!profile) return yield* Effect.die("Expected a disposable validation profile."); profiles.add(profile); const helper = command.args[0] === "-e"; + // The runtime unpacks straight into the disposable profile. + if (!helper) expect(command.options.env?.TMPDIR).toBe(profile); const output = yield* Queue.unbounded(); const exited = yield* Deferred.make(); const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 24eb4e3d6df8..75baf453da03 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -476,6 +476,9 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma profileDirectory, platform, baseEnv: environment, + // The profile is scoped, so it cleans up the unpack; a shallow + // root keeps it under Windows' path limit. + tempDirectory: profileDirectory, }); const runtime = yield* makeAntigravityAcpRuntime({ spawn: buildAntigravityAcpSpawnInput({ diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index cc9ccb074b9e..18f7aaffee06 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -31,8 +31,7 @@ import { } from "../AntigravityInstallation.ts"; import { ANTIGRAVITY_AUTH_STDOUT_PREFIX, - resolveAntigravityProfileDirectory, - resolveAntigravityRuntimeTempDirectory, + resolveAntigravityInstanceDirectories, } from "../antigravityAuthSupport.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; @@ -74,7 +73,8 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const requestLog = path.join(root, "requests.jsonl"); - const profileDirectory = resolveAntigravityProfileDirectory(config.stateDir, instanceId); + const directories = yield* resolveAntigravityInstanceDirectories(config.stateDir, instanceId); + const profileDirectory = directories.profile; const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`; const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( @@ -233,6 +233,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( fs, path, profileDirectory, + directories, instancePath, first, second, @@ -475,7 +476,7 @@ it.layer(testLayer)("AntigravityDriver", (it) => { () => Effect.gen(function* () { const h = yield* makeHarness(); - const tempRoot = resolveAntigravityRuntimeTempDirectory(h.profileDirectory); + const tempRoot = h.directories.runtimeTemp; yield* h.refresh(); yield* h.refresh(); const directories = h.launches.flatMap((launch) => @@ -498,12 +499,17 @@ it.layer(testLayer)("AntigravityDriver", (it) => { const path = yield* Path.Path; const config = yield* ServerConfig; const instanceId = ProviderInstanceId.make("antigravity-orphan-sweep"); - const tempRoot = resolveAntigravityRuntimeTempDirectory( - resolveAntigravityProfileDirectory(config.stateDir, instanceId), + const directories = yield* resolveAntigravityInstanceDirectories( + config.stateDir, + instanceId, ); - const orphan = path.join(tempRoot, "run-orphan", "_MEI123", "google3"); - yield* fs.makeDirectory(orphan, { recursive: true }); - yield* fs.writeFileString(path.join(orphan, "payload.bin"), "stale"); + // Older builds unpacked inside the profile. + const legacyRoot = path.join(directories.profile, "antigravity-acp", "tmp"); + for (const root of [directories.runtimeTemp, legacyRoot]) { + const orphan = path.join(root, "run-orphan", "_MEI123", "google3"); + yield* fs.makeDirectory(orphan, { recursive: true }); + yield* fs.writeFileString(path.join(orphan, "payload.bin"), "stale"); + } yield* AntigravityDriver.create({ instanceId, displayName: "Sweep", @@ -519,7 +525,8 @@ it.layer(testLayer)("AntigravityDriver", (it) => { }), ), ); - expect(yield* fs.exists(tempRoot)).toBe(false); + expect(yield* fs.exists(directories.runtimeTemp)).toBe(false); + expect(yield* fs.exists(legacyRoot)).toBe(false); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1141ac5856fc..fb9c7041b5fd 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -33,8 +33,7 @@ import { buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, prepareAntigravityProfile, - resolveAntigravityProfileDirectory, - resolveAntigravityRuntimeTempDirectory, + resolveAntigravityInstanceDirectories, type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; import { @@ -103,15 +102,34 @@ export const AntigravityDriver: ProviderDriver + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not resolve the Antigravity profile directory.", + cause, + }), + ), ); + const profileDirectory = directories.profile; // No process of this instance exists yet, so every runtime temp - // directory left under the profile is an orphan from a killed server. - yield* removeAntigravityRuntimeTempDirs( - resolveAntigravityRuntimeTempDirectory(profileDirectory), - ).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + // directory it owns is an orphan from a killed server. Older builds + // unpacked inside the profile. + for (const directory of [ + directories.runtimeTemp, + path.join(profileDirectory, "antigravity-acp", "tmp"), + ]) { + yield* removeAntigravityRuntimeTempDirs(directory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + } const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER, instanceId, @@ -165,6 +183,7 @@ export const AntigravityDriver: ProviderDriver Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")), + ), + ), +); + +const noSpawner = ChildProcessSpawner.make(() => + Effect.die("Disabled Grok must not spawn a process"), +); + +// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + +it.layer(testLayer)("GrokDriver", (it) => { + it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" }); + const grokHome = path.join(tempDir, "Grok Home"); + const binaryPath = path.join(grokHome, "bin", "grok"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-update"), + displayName: "Grok test", + enabled: false, + environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], + config: { ...GrokDriver.defaultConfig(), binaryPath }, + }); + + const capabilities = yield* instance.snapshot.resolveMaintenance(); + expect(capabilities.packageName).toBe("@xai-official/grok"); + expect(capabilities.update).toMatchObject({ + command: `'${binaryPath}' update`, + executable: binaryPath, + args: ["update"], + }); + // `grok update` installs under GROK_HOME, so it must target this instance's home. + expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), + Effect.scoped, + ), + ); + + it.effect("stays manual-only when the configured executable does not exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" }); + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-missing"), + displayName: "Grok test", + enabled: false, + environment: [], + config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") }, + }); + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), + Effect.scoped, + ), + ); +}); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5f0cf4d90c71..3f2c2ece4308 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -29,7 +29,13 @@ import { import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { discoverGrokSkills } from "./GrokSkills.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -38,10 +44,32 @@ import { const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); -const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, -}); +// npm's `latest` tracks Grok's stable channel, the one `grok update` installs +// by default, so the registry stays the source for "latest". +const GROK_NPM_PACKAGE = "@xai-official/grok"; +// `grok update` finds the installer that owns the binary itself, so the +// resolved executable is its own updater. It installs under `GROK_HOME`, so it +// runs with the instance's environment. No executable means nothing to update, +// not "whatever is on PATH". +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (context) => + Effect.succeed( + context + ? makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["update"], + updateLockKey: "grok", + platform: context.platform, + env: context.env, + }) + : makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + }), + ), +}; export type GrokDriverEnv = | BackgroundPolicy.BackgroundPolicy @@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies GrokSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver = { const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver = { buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - enrichGrokSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities: MAINTENANCE_CAPABILITIES, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - httpClient, - }), + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichGrokSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + ), + ), }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9f464bdaa177..fc2e365f6e9c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -2383,6 +2383,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { method: "item/tool/requestUserInput", requestId: ApprovalRequestId.make("req-user-input-1"), payload: { + isBlocking: true, itemId: "item-user-input-1", threadId: "thread-1", turnId: "turn-1", diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index cdf40f73b1bd..d99b97150c5c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -441,7 +441,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun requestAllCodexModels(client), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. - client.request("account/rateLimits/read", undefined).pipe( + client.request("account/rateLimits/read", null).pipe( Effect.map((response): CodexRateLimitsProbe => ({ snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ec113ab7c521..588c24dd1176 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -90,25 +90,28 @@ describe("Codex thread history", () => { ); } - it.effect("keeps the count-based rollback API for older threads", () => + it.effect("surfaces Codex rejecting a revert of a legacy thread", () => Effect.gen(function* () { + const rejection = CodexErrors.CodexAppServerRequestError.invalidRequest( + "thread/revert only supports paginated threads", + ); const client: Parameters[0] = { - raw: { request: () => Effect.succeed({ thread: {} }) }, - request: ( - method: M, - params: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - NodeAssert.equal(method, "thread/rollback"); - NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 }); + raw: { + request: (method) => { + if (method === "thread/read") return Effect.succeed({ thread: {} }); + if (method === "thread/revert") return Effect.fail(rejection); + return Effect.die(`Unexpected raw request: ${method}`); + }, + }, + request: (method: M) => { + NodeAssert.equal(method, "thread/read"); return Effect.succeed({ - thread: { id: "legacy-thread", turns: [] }, + thread: { id: "legacy-thread", turns: [{ id: "turn-1", items: [] }] }, } as unknown as CodexRpc.ClientRequestResponsesByMethod[M]); }, }; - NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), { - threadId: "legacy-thread", - turns: [], - }); + const error = yield* Effect.flip(rollbackCodexThread(client, "legacy-thread", 1)); + NodeAssert.strictEqual(error, rejection); }), ); }); @@ -680,6 +683,7 @@ function makeThreadStartedNotification( id: threadId, modelProvider: "openai", preview: "", + projectId: null, sessionId: threadId, source, status: { type: "idle" as const }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 674d23327b65..ba78589bfdb1 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -119,7 +119,7 @@ const McpElicitationFormField = Schema.Struct({ type: Schema.optionalKey(NullableMcpElicitationString), title: Schema.optionalKey(NullableMcpElicitationString), description: Schema.optionalKey(NullableMcpElicitationString), - default: Schema.optionalKey(Schema.Unknown), + default: Schema.optionalKey(Schema.Json), enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), oneOf: Schema.optionalKey( @@ -163,8 +163,7 @@ export type CodexTurnStartParamsWithCollaborationMode = export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; type CodexServiceTier = NonNullable; type CodexThreadItem = - | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] - | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number]; + EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number]; export interface CodexSessionRuntimeOptions { readonly threadId: ThreadId; @@ -442,7 +441,7 @@ export function toMcpElicitationResponse( ? "always" : undefined; const form = mcpElicitationFormFields(payload); - const content: Record = {}; + const content: Record = {}; for (const [key, field] of Object.entries(form?.properties ?? {})) { const options = mcpElicitationFieldOptions(field); @@ -1185,7 +1184,7 @@ function updateSession( } function parseThreadSnapshot( - response: EffectCodexSchema.V2ThreadReadResponse | EffectCodexSchema.V2ThreadRollbackResponse, + response: EffectCodexSchema.V2ThreadReadResponse, ): CodexThreadSnapshot { return { threadId: response.thread.id, @@ -1273,11 +1272,8 @@ export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* ( threadId: string, numTurns: number, ): Effect.fn.Return { - if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") { - return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns })); - } - // Paginated threads replace history at a turn boundary instead of supporting - // the legacy count-based rollback endpoint. + // Codex replaces history at a turn boundary. It rejects threads that still + // use legacy history, which have no rollback API since Codex 0.156. const snapshot = yield* readCodexThread(client, threadId); const retainedCount = Math.max(0, snapshot.turns.length - numTurns); const firstRemoved = snapshot.turns[retainedCount]; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 45a5cadb9a31..dde5979c0f56 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -824,6 +824,54 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps one answer when an earlier tool reports progress mid-stream", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 9))); + // The coalesced progress tick emits nothing, and neither the completion + // nor a repeated one splits the markdown table across items. + expect(notes.map((note) => note._tag)).toEqual([ + "ToolCallUpdated", + "AssistantItemStarted", + "ContentDelta", + "ContentDelta", + "ToolCallUpdated", + "ContentDelta", + "ToolCallUpdated", + "ContentDelta", + "AssistantItemCompleted", + ]); + const itemIds = new Set( + notes.flatMap((note) => + note._tag === "ContentDelta" || + note._tag === "AssistantItemStarted" || + note._tag === "AssistantItemCompleted" + ? [note.itemId] + : [], + ), + ); + expect(itemIds.size).toBe(1); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("emits status-only tool updates through completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 77517c44ea9b..c6b93cf20546 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -40,6 +40,8 @@ import { type AcpToolCallState, } from "./AcpRuntimeModel.ts"; +const MAX_SHOWN_TOOL_CALL_IDS = 256; + interface AcpToolCallTrackedState { readonly state: AcpToolCallState; readonly lastEmittedDetailLength: number | undefined; @@ -334,6 +336,9 @@ export const make = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + // Recently shown tool calls. A late update to a finished call is not a new + // boundary in the answer, although its progress state is gone. + const shownToolCallIds = new Set(); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -525,6 +530,7 @@ export const make = ( modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params: notification, @@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({ modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params, @@ -1186,6 +1193,7 @@ const handleSessionUpdate = ({ readonly modeStateRef: Ref.Ref; readonly configOptionsRef: Ref.Ref>; readonly toolCallsRef: Ref.Ref>; + readonly shownToolCallIds: Set; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; @@ -1202,11 +1210,7 @@ const handleSessionUpdate = ({ } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - yield* closeActiveAssistantSegment({ - queue, - assistantSegmentRef, - }); - const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { const tracked = current.get(event.toolCall.toolCallId); const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); @@ -1228,11 +1232,22 @@ const handleSessionUpdate = ({ skippedSinceEmit: decision.skippedSinceEmit, }); } - return [{ merged: nextToolCall, decision }, next] as const; + return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; }); if (!decision.emit) { continue; } + // A new tool call is a boundary in the prose. Progress on a call that + // is already shown, such as a background command finishing, is not. + if (!shownToolCallIds.has(merged.toolCallId)) { + shownToolCallIds.add(merged.toolCallId); + // Only recent calls get late updates; keep a long session bounded. + if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { + shownToolCallIds.delete(shownToolCallIds.values().next().value!); + } + // A call still running is already on screen, even if it aged out. + if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); + } yield* Queue.offer(queue, { _tag: "ToolCallUpdated", toolCall: merged, diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index 07d66065d9ee..f20640bac0a0 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -43,10 +43,10 @@ export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySession ); /** - * Removes every per-process runtime temp directory under the profile. Call - * once when the driver starts, before it launches any process, so a previous - * server that was killed mid-session cannot leave unpacked runtimes behind. - * Only the profile-owned directory is touched. The system temp directory + * Removes every per-process runtime temp directory under an instance's root. + * Call once when the driver starts, before it launches any process, so a + * previous server that was killed mid-session cannot leave unpacked runtimes + * behind. Only T3-owned directories are touched. The system temp directory * belongs to other programs and Windows does not lock data files, so sweeping * it could gut a live extraction. */ diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 01fe516454ea..c2402c87c3ca 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -1,6 +1,8 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import { @@ -11,6 +13,7 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -36,7 +39,7 @@ import { makeAntigravityStdoutTransform, parseAntigravityAuthorizationUrl, prepareAntigravityProfile, - resolveAntigravityProfileDirectory, + resolveAntigravityInstanceDirectories, } from "./antigravityAuthSupport.ts"; const authorizationUrl = @@ -246,20 +249,44 @@ describe("Antigravity process environment", () => { } }); - it("keeps accounts separate even when instance IDs differ only by case", () => { - const first = resolveAntigravityProfileDirectory( - "/userdata", - ProviderInstanceId.make("antigravity"), - ); - const second = resolveAntigravityProfileDirectory( - "/userdata", - ProviderInstanceId.make("Antigravity"), - ); - expect(first.toLowerCase()).not.toBe(second.toLowerCase()); - expect( - resolveAntigravityProfileDirectory("/userdata", ProviderInstanceId.make("antigravity")), - ).toBe(first); - }); + it.effect("keeps accounts separate even when instance IDs differ only by case", () => + Effect.gen(function* () { + const first = yield* resolveAntigravityInstanceDirectories( + "/userdata", + ProviderInstanceId.make("antigravity"), + ); + const second = yield* resolveAntigravityInstanceDirectories( + "/userdata", + ProviderInstanceId.make("Antigravity"), + ); + // Existing sign-ins live at this path; it must not move. + expect(first.profile).toBe( + "/userdata/providers/antigravity/ac0a3dfd6dddb20962cecff6ee5fe65e19d3923be20e52c5ab52ff877f7e4c32", + ); + expect(first.profile.toLowerCase()).not.toBe(second.profile.toLowerCase()); + expect(first.runtimeTemp.toLowerCase()).not.toBe(second.runtimeTemp.toLowerCase()); + }).pipe(Effect.provide(Layer.mergeAll(NodeCrypto.layer, NodePath.layerPosix))), + ); + + it.effect("keeps the unpacked Windows runtime under MAX_PATH for long user names", () => + Effect.gen(function* () { + const path = yield* Path.Path; + // Deepest member of the official agy_acp_server_1.1.1 windows-x86_64 bundle. + const deepestMember = + "google3\\cloud\\developer_experience\\antigravity_extensions\\acp_server\\_private__agy_acp_server_bin.lazy_imports_info.json"; + const directories = yield* resolveAntigravityInstanceDirectories( + "C:\\Users\\a-twenty-char-person\\.t3\\userdata", + ProviderInstanceId.make("antigravity"), + ); + const extracted = (tempDirectory: string) => + path.join(tempDirectory, "run-AbC123", "_MEI000012ab2", deepestMember); + // MAX_PATH is 260 including the terminating NUL. + expect(extracted(directories.runtimeTemp).length).toBeLessThan(260); + expect( + extracted(path.join(directories.profile, "antigravity-acp", "tmp")).length, + ).toBeGreaterThanOrEqual(260); + }).pipe(Effect.provide(Layer.mergeAll(NodeCrypto.layer, NodePath.layerWin32))), + ); }); describe("Antigravity authorization URL", () => { diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index b48368ad2a1d..fc7fcb0854cf 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -1,13 +1,12 @@ -import * as NodeCrypto from "node:crypto"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. import * as NodeFSP from "node:fs/promises"; -// @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. -import * as NodePath from "node:path"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; @@ -185,19 +184,32 @@ export function isAntigravitySignInRequiredError(error: unknown): boolean { ); } -/** Keeps case-sensitive instance IDs separate on case-insensitive filesystems. */ -export function resolveAntigravityProfileDirectory( - stateDir: string, - instanceId: ProviderInstanceId, -): string { - const directoryName = NodeCrypto.createHash("sha256").update(instanceId).digest("hex"); - return NodePath.join(stateDir, "providers", "antigravity", directoryName); +export interface AntigravityInstanceDirectories { + /** GEMINI_HOME for the agent. Holds the instance's Google sign-in. */ + readonly profile: string; + /** + * Parent of the per-process directories the agent unpacks into. It sits + * beside the profile, not inside it: the agent unpacks members up to 120 + * characters deep, and the profile's longer name would push them past + * Windows' 260-character path limit. + */ + readonly runtimeTemp: string; } -/** Parent of the per-process runtime temp directories inside a profile. */ -export function resolveAntigravityRuntimeTempDirectory(profileDirectory: string): string { - return NodePath.join(profileDirectory, "antigravity-acp", "tmp"); -} +/** Hashes the instance ID so case-only differences stay separate on case-insensitive filesystems. */ +export const resolveAntigravityInstanceDirectories = Effect.fn( + "resolveAntigravityInstanceDirectories", +)(function* (stateDir: string, instanceId: ProviderInstanceId) { + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + const key = Encoding.encodeHex( + yield* crypto.digest("SHA-256", new TextEncoder().encode(instanceId)), + ); + return { + profile: path.join(stateDir, "providers", "antigravity", key), + runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)), + } satisfies AntigravityInstanceDirectories; +}); function quoteBrowserArgument(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; @@ -300,6 +312,8 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( readonly auth?: AntigravityAuthConfig; /** Home the agent expands `~` against. Defaults to the launch environment's. */ readonly userHome?: string; + /** Parent of per-process temp directories. Defaults to one inside the profile. */ + readonly tempDirectory?: string; }) { const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; const fs = yield* FileSystem.FileSystem; @@ -337,7 +351,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const geminiHome = path.resolve(input.profileDirectory); const acpDirectory = path.join(geminiHome, "antigravity-acp"); - const tempDirectory = resolveAntigravityRuntimeTempDirectory(geminiHome); + const tempDirectory = input.tempDirectory ?? path.join(acpDirectory, "tmp"); const profile: AntigravityProfile = { platform, geminiHome, diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 69e51bcb019a..b7531c21c981 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,14 +1,15 @@ { "version": 1, - "updatedAt": "2026-09-24T00:11:29Z", + "updatedAt": "2026-09-24T18:40:00Z", "compatibility": [ { "driver": "codex", "t3CodeRange": ">=0.0.42", - "recommendedRange": ">=0.129.0", + "recommendedRange": ">=0.156.0", "ranges": [ - { "range": ">=0.129.0", "status": "supported" }, - { "range": "<0.129.0", "status": "broken" } + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } ] }, { diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 3a43356d2caf..4d438a77b54e 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -65,6 +65,24 @@ describe("provider compatibility", () => { } }); + it("supports Codex 0.156 and marks Codex without Thread.projectId broken", () => { + const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility; + for (const [t3CodeVersion, codexVersion, expected] of [ + ["0.0.42", "0.148.0", "broken"], + ["0.0.42", "0.149.0", "unsupported"], + ["0.0.42", "0.155.0", "unsupported"], + ["0.0.42", "0.156.0", "supported"], + ["0.0.43-nightly.20260924.2200", "0.153.3", "unsupported"], + ["0.0.43-nightly.20260924.2200", "0.156.1", "supported"], + ] as const) { + assert.strictEqual( + resolveProviderCompatibility(bundled, driver, codexVersion, t3CodeVersion)?.status, + expected, + `T3 Code ${t3CodeVersion} with Codex ${codexVersion}`, + ); + } + }); + it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { diff --git a/apps/server/src/provider/testFixtures/codexMultiAgentWire.json b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json index 08316d3b6334..0f183635b17e 100644 --- a/apps/server/src/provider/testFixtures/codexMultiAgentWire.json +++ b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json @@ -20,6 +20,7 @@ "forkedFromId": null, "parentThreadId": null, "preview": "", + "projectId": null, "ephemeral": false, "historyMode": "legacy", "modelProvider": "openai", @@ -389,6 +390,7 @@ "forkedFromId": null, "parentThreadId": null, "preview": "", + "projectId": null, "ephemeral": false, "historyMode": "legacy", "modelProvider": "openai", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b42d001ca138..20c2a3d83251 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -562,6 +562,7 @@ const buildAppUnderTest = (options?: { >; relayClient?: Partial; cloudCliTokenManager?: Partial; + httpClient?: HttpClient.HttpClient; nativeTelemetryClient?: Partial; desktopTelemetryReceiver?: Partial< DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"] @@ -1176,6 +1177,9 @@ const buildAppUnderTest = (options?: { CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ applyConfig: () => Effect.succeed({ status: "disabled" }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, ...options?.layers?.cloudManagedEndpointRuntime, }), ), @@ -1224,7 +1228,11 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(makeAuthTestLayer()), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), - Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge( + options?.layers?.httpClient === undefined + ? FetchHttpClient.layer + : Layer.succeed(HttpClient.HttpClient, options.layers.httpClient), + ), Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), Layer.provide(layerConfig), ); @@ -3223,6 +3231,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects a non-Cloudflare managed endpoint runtime without persisting the link", () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ status: "unsupported", providerKind: config.providerKind } as const); + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "manual", + connectorToken: "manual-token", + }, + }), + }); + const relayConfigBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly endpointRuntimeStatus?: { readonly status?: string }; + }>(relayConfigResponse); + const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state"); + const linkStateResponse = yield* fetchEffect(linkStateUrl, { + headers: { cookie: ownerCookie }, + }); + const linkStateBody = yield* responseJsonEffect<{ readonly linked?: boolean }>( + linkStateResponse, + ); + + assert.equal(relayConfigResponse.status, 503); + assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); + assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "unsupported"); + // The connector is never touched for a rejected runtime. + assert.deepEqual(appliedRuntimeConfigs, []); + assert.equal(linkStateResponse.status, 200); + assert.equal(linkStateBody.linked, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("reports local cloud link state from persisted relay config", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3301,6 +3371,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("unlinks local cloud state and disables the managed endpoint runtime", () => Effect.gen(function* () { const appliedRuntimeConfigs: Array = []; + const requestedRecoveryConfigs: Array = []; yield* buildAppUnderTest({ layers: { cloudManagedEndpointRuntime: { @@ -3317,7 +3388,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), }); }, + requestRecovery: (config) => + Effect.sync(() => { + requestedRecoveryConfigs.push(config); + }), }, + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ status: "ready" }))), + ), }, }); @@ -3383,6 +3461,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(linkStateBody.relayUrl, null); assert.equal(linkStateBody.relayIssuer, null); assert.deepEqual(appliedRuntimeConfigs, [ + null, { providerKind: "cloudflare_tunnel", connectorToken: "connector-token", @@ -3391,6 +3470,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, null, ]); + assert.deepEqual(requestedRecoveryConfigs, []); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3701,19 +3781,169 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps a managed connector stopped when relay registration fails", () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + const relayRequests: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ status: "running", providerKind: "cloudflare_tunnel", pid: 123 } as const); + }), + }, + httpClient: HttpClient.make((request) => + Effect.sync(() => { + relayRequests.push(request); + return HttpClientResponse.fromWeb( + request, + Response.json({ message: "relay unavailable" }, { status: 503 }), + ); + }), + ), + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + }), + }); + const relayConfigBody = yield* responseJsonEffect<{ readonly _tag?: string }>( + relayConfigResponse, + ); + + assert.equal(relayConfigResponse.status, 500); + assert.equal(relayConfigBody._tag, "EnvironmentHttpInternalServerError"); + assert.equal(relayRequests.length, 3); + assert.deepEqual(appliedRuntimeConfigs, [null]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "queues recovery without starting a connector when relay registration requires it", + () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + const requestedRecoveryConfigs: Array = []; + const relayRequests: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ status: "running", providerKind: "cloudflare_tunnel", pid: 123 } as const); + }), + requestRecovery: (config) => + Effect.sync(() => { + requestedRecoveryConfigs.push(config); + }), + }, + httpClient: HttpClient.make((request) => + Effect.sync(() => { + relayRequests.push(request); + return HttpClientResponse.fromWeb( + request, + Response.json({ status: "recovery_required" }), + ); + }), + ), + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + }), + }); + const relayConfigBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly endpointRuntimeStatus?: { readonly status?: string }; + }>(relayConfigResponse); + + assert.equal(relayConfigResponse.status, 503); + assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); + assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "disabled"); + assert.equal(relayRequests.length, 1); + assert.deepEqual(appliedRuntimeConfigs, [null]); + assert.deepEqual(requestedRecoveryConfigs, [ + { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("fails relay config when the managed endpoint connector cannot start", () => Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; yield* buildAppUnderTest({ layers: { cloudManagedEndpointRuntime: { - applyConfig: () => - Effect.succeed({ - status: "failed", - providerKind: "cloudflare_tunnel", - reason: "cloudflared missing", - tunnelId: "tunnel-1", + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ + status: "failed", + providerKind: "cloudflare_tunnel", + failure: "not-installed", + reason: "cloudflared missing", + tunnelId: "tunnel-1", + } as const); }), }, + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ status: "ready" }))), + ), }, }); @@ -3752,33 +3982,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigBody.message, "Managed endpoint runtime could not be started."); assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "failed"); assert.equal(relayConfigBody.endpointRuntimeStatus?.reason, "cloudflared missing"); - - const now = yield* DateTime.now; - const healthRequest = makeCloudEnvironmentHealthRequest({ - privateKey: cloudKeyPair.privateKey, - environmentId: testEnvironmentDescriptor.environmentId, - nonce: "cloud-health-after-failed-runtime", - issuedAt: DateTime.formatIso(now), - expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), - }); - const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); - const healthResponse = yield* fetchEffect(healthUrl, { - method: "POST", - headers: { - "content-type": "application/json", + assert.deepEqual(appliedRuntimeConfigs, [ + null, + { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", }, - body: jsonRequestBody(healthRequest), - }); - const healthBody = yield* responseJsonEffect<{ - _tag?: string; - message?: string; - }>(healthResponse); - assert.equal(healthResponse.status, 500); - assert.equal(healthBody._tag, "EnvironmentHttpInternalServerError"); - assert.equal( - healthBody.message, - "Cloud mint public key is not installed for this environment.", - ); + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e648b3b50aec..4f264ae1cb0d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -8,12 +8,16 @@ import { ProviderDriverKind, type RepositoryIdentity, } from "@t3tools/contracts"; +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Random from "effect/Random"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -123,12 +127,21 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, pendingServiceUpdateExists, - reconcileDesiredCloudLink, + reconcileDesiredCloudLinkIfStillDesired, + recoverManagedCloudTunnel, + registerManagedCloudTunnelRecovery, + startManagedCloudTunnelIfOriginConfirmed, releaseManagedTunnelOnShutdown, } from "./cloud/http.ts"; import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import { shouldRetryCloudLink } from "./cloud/relayResponse.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import { + MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, + MANAGED_TUNNEL_RECOVERY_COOLDOWN, + managedTunnelStartupAction, + retryManagedTunnelRegistration, +} from "./cloud/managedTunnelStartup.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; @@ -708,10 +721,6 @@ const makeServerLayer = Layer.unwrap( : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { - if (!hasCloudPublicConfig) { - yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); - return; - } const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( Effect.timeout("10 seconds"), Effect.tap((released) => @@ -740,33 +749,184 @@ const makeServerLayer = Layer.unwrap( if (!cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); } - if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) return; + const localOrigin = `http://127.0.0.1:${address.port}`; + const endpointRuntime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime; + const recoveryLock = yield* Semaphore.make(1); + let lastRecoveryAtMillis = 0; + const recoverManagedTunnel = (config: RelayManagedEndpointRuntimeConfig) => + recoveryLock.withPermits(1)( + Effect.gen(function* () { + const elapsed = (yield* Clock.currentTimeMillis) - lastRecoveryAtMillis; + const wait = Duration.toMillis(MANAGED_TUNNEL_RECOVERY_COOLDOWN) - elapsed; + if (wait > 0) yield* Effect.sleep(Duration.millis(wait)); + lastRecoveryAtMillis = yield* Clock.currentTimeMillis; + }).pipe( + Effect.andThen( + recoverManagedCloudTunnel(localOrigin, config, { + retryRuntimeFailures: true, + }), + ), + Effect.retry({ + while: (error) => + shouldRetryCloudLink(error) && + error._tag !== "EnvironmentCloudEndpointUnavailableError", + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ), + }), + Effect.tap((recovered) => + recovered ? Effect.logInfo("T3 Connect managed tunnel recovered") : Effect.void, + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { + cause, + }), + ), + ), + ); + yield* endpointRuntime.recoveryRequests.pipe( + Stream.runForEach(recoverManagedTunnel), + Effect.forkScoped, + ); // No settling delay before the first attempt: routes are already // serving by the time activation opens this gate (the startup // sequence awaits routesReady), and the retry schedule below // covers anything this sleep used to hedge against. Every // millisecond here is dead time on the path to remote // reachability after a restart. - yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( - Effect.retry({ - while: shouldRetryCloudLink, - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), + const wantsCliLink = hasCloudPublicConfig + ? yield* CloudCliState.readCliDesiredCloudLink.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read the desired T3 Connect link", { cause }).pipe( + Effect.as(false), + ), ), - Schedule.upTo({ duration: "10 minutes" }), - ), - }), - Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), + ) + : false; + // A failed read must not end this fiber before it registers + // recovery and starts consuming recovery requests. "managed" is + // what a missing value means, so it is the safe fallback. + const desiredCliLinkMode = wantsCliLink + ? yield* CloudCliState.readCliDesiredLinkMode.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read the desired T3 Connect link mode", { + cause, + }).pipe(Effect.as("managed" as const)), + ), + ) + : null; + // A publish-only link must not expose the host, even if a managed + // config from an earlier link is still stored. + const startedConfirmed = + desiredCliLinkMode === "publish_only" + ? false + : yield* startManagedCloudTunnelIfOriginConfirmed(localOrigin).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to start the confirmed T3 Connect tunnel", { + cause, + }).pipe(Effect.as(false)), + ), + ); + const startStoredManagedTunnel = startManagedCloudTunnelIfOriginConfirmed(localOrigin, { + requireConfirmedOrigin: false, + }).pipe( + Effect.tap((started) => + started + ? Effect.logWarning( + "T3 Connect started the stored tunnel without relay confirmation", + ) + : Effect.void, + ), Effect.catch((cause) => - Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { - message: cause.message, - }), + Effect.logWarning("Failed to start the stored T3 Connect tunnel", { cause }), ), + Effect.asVoid, ); + const registerManagedTunnel = retryManagedTunnelRegistration( + registerManagedCloudTunnelRecovery(localOrigin, { + retryRuntimeFailures: true, + }), + (error) => + shouldRetryCloudLink(error) && + error._tag !== "EnvironmentCloudEndpointUnavailableError", + startedConfirmed ? Effect.void : startStoredManagedTunnel, + ).pipe( + Effect.tap((result) => + result.status === "ready" + ? Effect.logInfo("T3 Connect managed tunnel recovery registered") + : Effect.void, + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to register T3 Connect managed tunnel recovery", { + cause, + }).pipe(Effect.as({ status: "unavailable" as const })), + ), + ); + // A host without a confirmed marker is on its first boot after the + // upgrade. Spread those registrations so an auto-update wave does + // not hit the relay all at once. + if (!startedConfirmed && desiredCliLinkMode !== "publish_only") { + const jitter = yield* Random.nextIntBetween( + 0, + Duration.toMillis(MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER), + ); + yield* Effect.sleep(Duration.millis(jitter)); + } + const registration = + desiredCliLinkMode === "publish_only" + ? { status: "not_linked" as const } + : yield* registerManagedTunnel; + // A terminal registration failure also allows the stored config + // to start. Transient outages use the fallback above and keep + // registration retrying in this scoped startup fiber. + if (registration.status === "unavailable" && !startedConfirmed) { + yield* startStoredManagedTunnel; + } + const startupAction = managedTunnelStartupAction({ wantsCliLink, registration }); + if (startupAction.action === "request_recovery") { + yield* endpointRuntime.requestRecovery(startupAction.config); + } + if (startupAction.action === "reconcile_link") { + const reconciledMode = yield* reconcileDesiredCloudLinkIfStillDesired( + localOrigin, + ).pipe( + Effect.retry({ + while: shouldRetryCloudLink, + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), + ), + }), + Effect.tap((mode) => + mode === null + ? Effect.void + : Effect.logInfo("T3 Connect desired link reconciled on startup"), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { + cause, + }).pipe(Effect.as(null)), + ), + ); + if (reconciledMode === "managed") { + const afterReconcile = yield* registerManagedTunnel; + if (afterReconcile.status === "recovery_required") { + yield* endpointRuntime.requestRecovery(afterReconcile.config); + } + } + } }), ); yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 97c0bd44eb96..0cf965c4bd22 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,6 +35,7 @@ import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { @@ -321,6 +322,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + ); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index d8a5f651ff83..cb25e6938fa7 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -179,6 +179,75 @@ describe("ChatMarkdown favicon privacy", () => { }); describe("ChatMarkdown streaming", () => { + it("runs only a complete single-line shell block after a click", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const onRunShellCommand = vi.fn(); + let renderer: ReactTestRenderer | undefined; + const message = (text: string, isStreaming = false) => ( + + ); + try { + await act(async () => { + renderer = create(message("```bash\necho hello\n```", true)); + }); + const mounted = renderer!; + expect( + mounted.root + .findAllByType(Button) + .some((button) => button.props["aria-label"] === "Run in terminal"), + ).toBe(false); + + await act(async () => { + mounted.update(message("```bash\necho hello\n```")); + }); + await act(async () => { + codeButton(mounted, "Run in terminal").onClick?.({} as never); + }); + expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello"); + + for (const text of [ + "~~~bash\necho tilde\n~~~", + "> ```bash\n> echo quote\n> ```", + "````bash\necho four\n````", + ]) { + await act(async () => { + mounted.update(message(text)); + }); + expect(codeButton(mounted, "Run in terminal")).toBeDefined(); + } + + for (const text of [ + "```bash\necho one\necho two\n```", + "```typescript\necho hello\n```", + "```bash\n\n```", + "```bash\necho hello\n\n```", + "```bash\necho hello\\\n```", + "```bash\necho safe \u202e#\n```", + "```bash\necho incomplete", + "~~~bash\necho incomplete", + "````bash\necho incomplete\n```", + '
echo html
', + ]) { + await act(async () => { + mounted.update(message(text)); + }); + expect( + mounted.root + .findAllByType(Button) + .some((button) => button.props["aria-label"] === "Run in terminal"), + ).toBe(false); + } + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("does not retokenize completed lines when streaming finishes", async () => { const highlighter = await getSyntaxHighlighterPromise("typescript"); const highlight = vi.spyOn(highlighter, "codeToHast"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5b6cdfde49d6..4a1f616aed3b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,6 +20,7 @@ import { MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, + PlayIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, @@ -215,6 +216,7 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; + onRunShellCommand?: ((command: string) => void) | undefined; /** Directory that anchors relative links and images; defaults to `cwd`. Set to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; @@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined { return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; } +function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean { + const start = node?.position?.start.offset; + const end = node?.position?.end.offset; + if (start === undefined || end === undefined) return false; + const source = text.slice(start, end); + const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0]; + // One class for the blockquote prefix: nested quantifiers here backtrack + // exponentially on code lines that start with many `> ` markers. + const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1]; + return ( + opening !== undefined && + closing !== undefined && + opening[0] === closing[0] && + closing.length >= opening.length + ); +} + type MarkdownAstNode = { type?: string; meta?: unknown; @@ -920,12 +939,16 @@ function MarkdownCodeBlock({ language, fenceTitle, theme, + onRunShellCommand, + isStreaming, children, }: { code: string; language: string; fenceTitle: string | null; theme: "light" | "dark"; + onRunShellCommand?: ((command: string) => void) | undefined; + isStreaming: boolean; children: ReactNode; }) { const [copied, setCopied] = useState(false); @@ -933,6 +956,17 @@ function MarkdownCodeBlock({ const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const command = code.trim(); + const canRun = + onRunShellCommand !== undefined && + !isStreaming && + /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && + code.endsWith("\n") && + command.length > 0 && + !command.endsWith("\\") && + // Control and invisible format characters (bidi overrides, zero-width) can + // make the rendered command differ from what the terminal would receive. + !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { @@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({ {wrapLabel} + {canRun ? ( + + onRunShellCommand(command)} + aria-label="Run in terminal" + /> + } + > + + + Run in terminal + + ) : null} {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); + const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use( + ChatMarkdownRendererContext, + ); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; @@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = { language={language} fenceTitle={fenceTitle} theme={resolvedTheme} + onRunShellCommand={ + onRunShellCommand && !isStreaming && isClosedCodeFence(node, text) + ? onRunShellCommand + : undefined + } + isStreaming={isStreaming} > { + runProjectScriptRef.current = runProjectScript; + }, [runProjectScript]); + const runShellCommand = useCallback((command: string) => { + void runProjectScriptRef.current( + { + id: "chat-code-block", + name: "Chat code block", + command, + icon: "play", + runOnWorktreeCreate: false, + }, + { rememberAsLastInvoked: false }, + ); + }, []); + const supportsProjectSettingsOverrides = environmentById.get(environmentId)?.serverConfig?.environment.capabilities .projectSettingsOverrides === true; @@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) { agentPanelModel, onOpenAgents: addAgentsSurface, onUseArtifactTemplate: useArtifactTemplate, + ...(activeProject ? { onRunShellCommand: runShellCommand } : {}), } : {})} isWorking={!paintOnlyDisplayedTimeline && isWorking} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5c9d37d8b4e5..2c26f1e69b1d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -636,7 +636,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { return (
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 23b10a36676e..2a4463c11a06 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -701,7 +701,7 @@ export const Gemini: Icon = (props) => ( ); const ANTIGRAVITY_ICON_DATA_URL = - "data:image/png;base64,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"; + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAQAElEQVR4nOx9CbhsVXXmWvucqst7AmpQwDmoDUgQpTtGmcRIBEHhAwxEMRpCjEOa2NoK7/Hw02drGGSUJE3brdGOHe2gaaLSUdMmxAAyPhklpuOswYHG/sLorTp776z/X+vUvQY1DHeoe9/deqnp1Kl6tdZew7/+tU6StbVVryRra6teawqwla81BdjK15oCbOWrla1o3XHB0/doyvCANJanSSc7aLa/WZ2RonfION0hpd7WZLlq3Xk3f162kqWyytftH3rGS+o4H6s5HdaM66M1N9WELzpWSblW7cwIdmq3glvha/7c55siF8/m2T995Adu/aGs0rUqFeDbFz1xXcrbnpyynKgm9JQhUKmJwsWfVgg7Zbsdi5oyVM0azwuVIJmi1M5ew/NFLzYF2rTuw9d8XVbZWnUK8K2Ld3+FlnSmjMvjG+zsYv/EkQkU93P127EdaEqho2LCTRXWwJ6rkhvRXOx4e81ukr1GBSlUIDFXceE9dw/e8dhPXnGXrJK1ahTg6xc/+1F1MP6QjPSl2hXBrtdSadqxwxsTvmQIv9rjVJMJ2IRLq1DHiYphFsEFnU34cBF2jOI8Zi1qp0qlyPLNLOm4R/zpVV+UVbBWRRbwpf+913NnB/XWcUkvzUlrSal2VWquardaXa6pmuBMtqnWJLhfqtrrYqbeVMVsgR1n7zV9MLWxcyiO8cf2vG0V0yB7X9Kn2KMrfvSrz/tdWQVrxSvAl/9y71+sST9rVvqxFGKF8BsTJm7VrDbvFyhDqU0pqSk4JptEcZwpQ8GxJmEKHvscAq/Y7/aaeRC7H3+NiV5wDB+/595f3eetssLXilaAL35u731mVf5PTml9B8Fjl3NXw9qbEMWEbTu92C53wUulkohbBgi6QAlMQUyJTNj4g4WwW2iG/TyVlsPcPxWkVwZ/r0UO75o9et+TZQWvFRsDbLl0n6fX0l1lQdv28NmWv1fuYUbwAj9eG0T+JnVFtD+q0mYEeoz8zcfTvzMWSIgF8NiE3thx6umgIoDEuZRZRKVtaMzUMDuwwxgj2NP2zAkzf3blR2UFrhWpAJddtv+jm9HoWhPCkyEsM+xVEPAVCA9CrQj6LOoXpntQiqaLgNAi+zQriAQ8+jcBtwgORx44th2VRF3wkQZakCjmQ/i44D2wGVACZgtqZqazkOGQ9Rd/4UpZYWtluoCS/8RM+5MzfL2Z67GJo9PWNmYLE17GJt6xNAj8BH5fzPwXuANt49ZkVuEWEPwl3s90DY2dp3FXInGLILKx6A+mP8H02+fDdSQEj/ifWQ+V1l686O4jnruTrLC14qDgSy/f703jXA7CPofttR1p+1AQ2EtmqM5QDs6e8mlgsZHWKQM8gDq1tSwBRqNRyNXeZP9p0sDiAcVOt2OZCZqc6ecR+eEBIwJEgBYrmJrYR9gXwCkSPlZlhySD99uRh8sKWivKBXzm8v1+oSnNVSakgUIWELBJsvSm3cw+c3i7NRNuOT6AHyG6Z767SLgC5Pt0EwCEzJ83hvqlcdam81gCmgQXQB8/TvY6XAFjC7qBhFygII2AWtj7CxWHamCKd/J2n7zsD2SFrBWjABdddEzzyCfcfpXZ6z0Fib35cCJ1kL7taqJ+UIrO9rEFdyGwwrhgTAi4wr+bsIgEQsCtxwUmeNYFCAXDvw8yj3c/DxTRtn0DZYLQsf1z8QAQsQL2vj3fwExQCXSUmvTM9f/rb74jK2CtGBew7on//w2jkvaE4C0flzRCCofVmlCJ2FoiaEJGaKbunBPdQaJLQLAD841UEOm8uQZoh7kBpIqGFpgczU3YYysBKC2+RRHY3gIUEL5GUoIlgVOw+MFOmuw16F8suhL7DsOc63n2+BhZAWtFWIBPXHXQTjk3N0vO23GfM+VTRv72splyz9GJ5xeYeSHub7uyKNI6yM8AgyZbdm8R/QC6M0akb6khYOJCV6F0DWZBWsC+Y1cKaJYJXZsxwkXsfksFgRxg9wNORjqYCRshwoB1EKIE0hy97Scu/axM+VoRWUAng3daVL5ttijf/hDt13FpZFztz/z3yAzxyFI4wLqAb/F4bFH6yKzDSAdlVDxDGCFTaOy5Oqg4VwF4xKi/QdZAFLEmOwZBIm7NBlRFgE8E0Xa9w8yZgBNBJ1gVgk/MEkwd7HXTzwYPzqmbp//3bWTK10evfPHP20b7Lz0ea0ISFOzghYniFaR6cMMNIdxsimF7t5ikYZPtcWsRP1E9Hos/QkZCbBc2AudU5BHiaSNhP7HzwLxofG5x04/sQgj9MD9wqTMXkMgVJXyB6KO77+/yrdO+/PWbZIrX1GuoGel35zwwy9yaNbZdWWzHpkEZ19YUoRXs6nHx3TqbB9V2u3SWuI8t5x/BUrAWgN07b7fbe0ZlILAk3cDSP5xPYDEMO+Bxg9Ilh48DF7DqcbLPN8GnxotGsASIH5QFJk8AEFzgNRaVoHz6tluO2WMoU7ymOgj88OVH7Gau+WhkWURcCoJwpn/YpRBCMT9gwiGzB45XILfioLD5fK/rm00wZ4Bw0DY8gj/4eoSQsAt2v0UWQaDfwf6Ck6OqaAfCmCcgPdnzTooa0WDhYyICNAjVY0+hLSk0KOavnvCk+x7/cpFb/1imdE21Aozb5g0I6iqTvgbYDjEZR/0J1ADhqRUpHP7H2q1IE0dhlyJQbG3H5wyYKKEsZFkAwCCTVG6FypSgRPZjQNoZ+WNLTWgZE9it+RygwQ1PWsy2QNwgmZhC2Hs72+74Gi3BIeQHDdJD5o32nVE2nloFmNos4AOXH7FdTcNv2EZbR8mjnFsc/jHbCv9dkP+j9AfejwE7ntcht8eOBhLYJQi4tID1LEMYVAeGmqwsEiGlaxC1W0bQQsUs8k+jogOTXEsmUNYh3oPIfxwAUM5pyM8EgMTaIWqJ/EMmYOEisgRYKOgGapGmQOng7S/5zFTWCabWAozSzG/Yj7y+0HDb78nd7cGXOVumewzKuDQCr8pdjl3d1ajuQQ9Q5EFcCEEzXEtmOJAHpEovDmNgCjEwcbbI8QpxfwBApIPgUxATJhM4cIaR7ewBviSMPfISdYi4wY7ne0wJ+JI7CTvsdXb0mgI8mGWO9/U5I2GHDBqm/IgBKiP1iF2ZeDUo/gtlVih8h+4h/JpYEqB9DmAIicAAcjGlQJHH/D9/hIYF3hy+38y+CbypiPoh1EJP0eBTEE9o40wCFhISy4O4jwAD9WX6B/KQXB0tRjjizqMO2mH7i//qDpmyNZUKcMHVLz+g6/RpyLWVFTmBItTiPyfZPEy5CNlBGZT7zuJB2418jWrQ0QLgPkAhZXaOoK8wCRSU8Fg8Ml0ATGy727PiglpxB78CX99ZRtjQBBUzA22akMbwOmL/6p9eGUtQ7lACuBcYqswgph3Xdb9up36vTNmaSgWw4Oy3slf0yMrJXod1KID1u8YtAzN9CBSYfMsgfgTsv3poo0jZ7ByG/FE5EApAxDDTMPs4tJhrgN8vqPKZZpj8rNLE8EJBJEXa1yJjQFGZT9AXINZnBRJKNbYPblF4gA40hZYACQo+PCVgDxn68VqZQgWYuiDwvEuPf1QZ1u9x12d1UCfjt2wY++MW1qB2ZH8yzGZRGHEBIoOe2WuvUSVQ6UOJF8d0iOBJ6aiNFXkGxPTs/phBnG1TE34hpCOtKUWL4039TKZqWxjZAFQPloPwL19HjFCcYtpWFokYEDoTEeedCxTti73k8RdfcrlM0Zo6C1Bm0iu7XLlLweQuhYImvCrCTaX0tSBowSubC8CGhJMuUApG3uoJuecHrAUQrwdKn5z+xeKdvb/N7kQgRDcclQEnLYzleXwDrEWidskgFwYcCeXEIBsgjBwQZDDcALloeISWcASSA1gIZJQJbmCqFGDqkEAz1SdksnkQcg3NFRsCyGgbeH9r4dZAkJyNu4F2RAMbMcTP/xL3q0XphvDZcSMZWv6erB7Uyqw5grHF7rN1aFXfGQsf7DXhn6GLhhPYc3zdnh81A3vfoOLPagkVzxk6aFmlfa79jSxWyPa8IZKGOA8Q8wNltOdaoIisExS8374TagrVUUgoylG3HX74epmiNVUW4IzLTnhWZyXfSmgXvhNFPcusgddzbykDLtB3EdYFq9e3M/r5CMsqk/TETAHFXpaGmSOijtAyhENOX7D/EWZ4+tZhxxbAuQJkhx4lJzggU4vqoE4HmkGWIflA8CkoJGvgUs4SSeo0pBbAE2rOGSXnxOfti8+UYTraPvJ/yJSsqVIA2zGvQt6fvXgDYTLZqjT9ZhEsbyueCjLkLplkMGQFCsoXbThDM/zXXQMTNyKAwAK0jODLGdtZ1jAyqAlUcJh3CLj4a0gi2uoMD4QeaCMBhWzA4l82S5MV+f+AfQWZeT/jxMQuBIeTNXnaqE5Wayr1Dsug4TUFuP8CIeey9GvZsmxsJbjazJy/rV7t4561TYl+PcgaX92PQUxQ0BIkvRNHnq7E8Bn6M2mDIwd4jLgxEdVLLC0A3TOBi29ilvghcGaSibUHtAojFCEUaSJuQUgIwSOZLAYlMnOAKUBcYLue6WJ2cgJPQkcBNSz7fe2IV+z01E9+9PsyBWtqFODUv/33B5hwd+x6+q24Jche7gXzB25ArGynMPUoBdtrpG1XYvbI1ZuoCzhNlBgilQGgDvEE27UQLyBblhEMVEjVwzQzNybLBh0j9jm0Oybowm8HcLewmoREEbhAG8hEzZ22pIAAJXSb5TgAmgqql6sT6oaF1ecWNPLh4Eg76ftkCtb0WABNx3Tmz5nmiRDtg39nqZVKgGY9YAJI7iDMvl7vlqGK1/NZIOoIv9kmJYhrcmioJ2DxkOtFfEGiYIOiDoqKJZoAveSE5gJL6hMLPzTmGbGA6UILbFkIP2qYeCsVtsCem8xSI9xFi6JEgiolEFIrPyyjDAm1zEfJmgL8+DLU7mXw6R36sqr7d+xl+H77eenrq3P3oRiK35I7k4qhvvtB9M4EbYHIIiSz7WyRuO1XEjktBrANmyxZILaAmMFwPgVQ1DJM9DDCc0qAQZ3zRHA/9UAwgAQoJtLGzNgBhYJiwh8QCmZJET0HwAXIJbT4tY5RFDLhj5EjaHruLcccv/OeH/vQ92SZ11QowIZL37K/Wd7HeN9eQwYPzD3A+q6jOwiTj7CfUAx3qWcA9qMDc4WCoA6HzQlLALgHNE+D5qAt1dI9cProHNgpCrROuZthGFjWzaR61gxhmqAtyBOPLbP6D+WccWGG0KAcWAvr1Rn0UtaHUTuw7wN3AFyAISi0oXWKMpSE7sQQwkPthB+UZV5ToQBjTYcz1SttAEDI+227Fk/T8GMzDawM3cQqsnbcgGANHDdpXgB+usbLAHYexOCE7akMA8JCVrczM16iuQPoHJs/GfbD2lg6WLsO4SVRR3LKKUAGfcIGA3AIUDRqlTtfhyggmbIOaBFSCNzTwxbWgD1rpoAZ4S0HDCjB7ZIOkzUF8GVAz9GCPmtxfAAAEABJREFU3B+7memep4AkVyL6J06PFLDlLYsCIHmQCI64i3EB+3iQKiLp6wpTQ9v9jTowl3gm0MpglfHOxLrR0MN3Myx5lBMDOpC+GiANRQwRtkegomZ1PjCifwA8EHLmpJGWbUKd2Rj8nJ4hwEmY27FPNPgI57Qc1AJGEXYqw6LkA759zJvXPelj590ny7iWXQFOvPSU3c3kP8Xz/4YKQMYtBI4dTd+bWKWBGLw0nHhrlsGTeNKCkgeCygKQ7TIL3+x1kjjh1HOY/+KkLbKGWBVi0o5GEQsRBshA7QxAAM0NILUDFk1uICBgBH4NTT7VCmchHNTJTJPqLJpKmrEdBkvTsT0ZWMEInYvFaWbOU8J3boa3DfNBdpJLZBnXsiuACe4wMn0h0NoyuifcT/MPc99wlyErgAkvxPLCUiANx17KYF3AFSgzBbKFePKWpF62itAMBD8AFJBSSSNAoYc5XybNUDlOACAE0T4I2sneLWmAEfgpaggljs/8PrMAhuhzCBzUYBiTstTCNSBeAInRUoQWDqKhA3uRbO0KYHj+ITT5kfYBVIOgPc3rKd0tf1gEWuaDmf8jNUMFsOO+bn3yA/Iu7WMCFnGUrd2UfnR2wh4QNhay+RN5A+r4MBBGfBmcovO+IYDRQKII+cDWd5kQkRWKoKVWb3Ba+sB5Q8qhQ96YFAoKAIp1ZUXPAfuOlOgUjM9BssxrWcvBr/3U5vVW/bvDhJ2Y9lWPA5DS0RIUNm/A1BMUQmnX9mIpHXH+ynSxNGwMLWD8FYL5zhIAX5CVwIbMDi/xmZ2AyNnjzzoiWkrB5rEKIUu4hJAGnBjE5BGzB+BMKsjoLQGljKjBbMxIaY9g9l1lLSlAiQhMY7sV0MbMNGWUndC14CWjNqMshBJy57XMkvf95Q9t/rIs01pWC5CH7UFouUEw1wM/gH6rR/oM7HIIWeqw0jWw5As3YQHZuPFdj8dWlfMacSIlLMFXe3aAOMEet0zyWRUGE8SqevAdUIPCHJ2WBQJFfAjQzvYoJkgMCkeJIBJ1N8Ie8zYNyPZhScjOMeOkQWYAg0TwgMWkQYMYwBTCMAVS2QaNp4qC4hYUp8IKbKUKUNMhHvh5DEAXwOAuHpd4XN3Pi+9FloY5xku8TgCwBwQR7Fmmg3wNUJ8pRWai4O16BIdpL8AlVAyVYFwfDKKWsb739rFtWLexbIKE8QqghzMFxIXLn86RP8vtM9oSTPbm7wEuIEZg1pCcIErQOXnqCGvBOTaJMYn5o1+xk/2hLNNaVj6ABXiHw69D2Kzt219nuTXuI8+vqP1XjwcAy1jdXtERBKAVwCwLRVbT93hhEGVi4noWx80QS+BxZcAUEnUBUxJLBWdYuDUjjc/iMfY55r1TypYLdNjd4ACYOMd1KONmoMji8VpNAwsrwC1o+d6x8nXhcw1em5HObpH8gUMAjsLYnAX5BFZDwHF4bZTwPjumndn/U69937JxBJbNArz602ftZf7+cQzyfOdXpE/0srQCyPkTIVgAQYX8GuzggQu8cAoYi0Zk5mXnCCSf7uUFo+xN4SRrFU77sE3LQNBwGbA24SgaFhEBBqDvPAbGRWUxikS4QeIAH5Eq1Q71xQRcgD7J2aAtcQtMnVCHiElj90kTAIicVJqZwbS0KIgGsjmJ8QtlmbKBZVOAcWkOK8H0IcDDXZ/8Fi1dLARZPNAx7ye6h51fOJipCep1cso4WBxsFEVxkHUB99XCEA9Bn3ozZyLxC+3eqOgBmMtkCyMLQI7P8h28takbAk/k7igYoPPMysAmtMyKUSagYK+nAfOI7Px/M1qDTu392XvMIOzqgBFxAGqaio8p5XybmDuXli0dXDYFMFP/YmG5d8Cijfv7SPtC2KwCFgaGQhI3hz22hF7B9gF/W6J0jFyfrdygCKMKyMJew2mPYpkDf3iiRk0NbWDNB8QR5wAPnDVgcUPOrETZJyWSBEgrqhJTIoWl54b5vY8qAD8dCkUNbMQnT/LkyRWR342DioBbYKqJVx1xy47j8iJZprUsaeAxF53xyDLc9gce5Q8C1UsBAg24GZHWYVcDEJI+OyiBACIFzO46MPKlcvc7p5+dH+wCUBL8i7sFniOoolAMzn9Baxd/BOQRJAdQytV7iZOziREUZq8YAhpGOufIQyEshW4hj1RAAyDEK4MYPGuqDWiLqR+dm78OpjDTxpbnG3u3Uir7vfz8V/+dLPFaFgvQzWx3UO2BnhC+m/9hZfcPzLYpAIGUCAALtx4COXVqL0h7QtiYwR2j/uK8AKC3jUS+3zCW4JAn8oLZzS+OKXDIH0sD5B+23ncM6IcugXUIzAeKOZHS2wEUiiFkFIIauiQFSji0+2hjN1BBkVH46AhvNgUA5MUH8/9tdrCRtkdpMqxsgDhg61AA270Hg3HvuD6E1vI2fLeDOg4IcZ95zAXT6ameU7Xt18zBAaieUhXyvhrO8QOkk8ASyi7vktgN4MQ8Yn4eF0QXTyLg61BhRTNH13nlUDkAjFGCOtvcg7sWlUNWdYXdQwOzWuMgrhH9M/yaytaw8ZAwpJ0YfAJS06B0UDEGhcxpul+WZUgHlyUNtLLvwVHZ8wAwtzThUALQuT0e4AAHDwzLwJWFytB4Pw7jhIHE+2KSc+slWVqKBmBSYs2vgx9u1QXNsrLJhpVGteohcf8Kj64tx0cVMoeSBXNNYtsAqvtO69aO3GHla2NL8TqJ1JKpIAEp4AtkEOD1MejoyVLGFlT0GYy7YZppqWJQzc2BtCg+zex/we/+xYws8VpyC3D4xz+4u0XeT6DfZsTfgz3qAmUXBn7kxtvA0IfrHcFUAMVoUMC7tQnuYO8yGoIuvsvRkev1A6cIqqOCGuaf+D3YY4kBISxHk2q4GQL49ONCCqlPkxzYfZSYYbcgvIYnKHyTTxtHUNIScsLn8KsQglJaHwQtjPpBRzIFHrLg3fnEC1irJs8YcrGvfblLZQnXkiuA5f4vikCOplUC6SPPP8rBNdwCyRw5drzTwvjjQfiO+Km7hj4TSG10ELc0/QzZJ0bOewpAAxLPwsQhYuf9es5eWV2E2clJozHd6Xwd1TSRQtaqD4LIHBlevUEV3gPTJxHQQFVZObBikb02QCbpdERPGSs4iChPJ/FUEefEACu6gdWtAMh5+1IvwB4Xdm/W+Ve9+tdG2zcDvxjkNKhe2VPWA7iTE2gfnN4XSqIheJL7fG4AEjzQw4qn5w4S8RmWbJW8nUgMixsMITOwbzysEkMIkC1g9oCiR8ArBc4AZJqqbA0mk6gj8lQYWTgITQuVECXA76PghT6izD6GyiDRNAd1gbfLEq4lTQMP/YsLZro7d/yB/egz/Y73wI+7vRLxo2AHXqyvLVm/tBRe5xcRrx3AfOcoCTt324s/ZO3ApHZuysWxPboDZdYf5H/SyNnU41NipO8qgBMhkMgO4riCgETXQV+qYhjpHYvOSW6qdy96xxEgLG9RQfWQuQ5Zg/aY1UCGspMGOIybGjj3SYb17l3fePpht8sSrSW1AKO7djrA/tkzLIKUNvh4PWs3IGH8dCFsZt7VLYMG7VvC/FvgqMRlgQx657+neTVF1O8wjgRVrLcIXtSrXpL3sf8+MDo6yDT2bXR5E+4tbBpXzoqH54aLIauQMKH6AALGFSj0SBDO2TkszlJHg2vn/eiIIkhZ9ggB/oYKzYjGikplW1iB/ylLtJZUASxeOpjWsrgvF/r3YPcQ2mWqF1F95Pxk5qQ5BnAfN2BfEscJtE+c+FHqnOknV6D65L4giMBN83nvKkjiE2Xo5ZlOak8aDV4PLIGy8p+9dkg2oDAeIG2UkBF7zon3kVVADEEIG7D3EKEkKpPebELGMGZbAKISgEQcQZd5MYqmaVavAlj6dwjbb2Dyq8Zu7hk8jZdxnd/v5hwCyl7/92CRFqD6ENgUbBti+qRz135SkPKqUP6AwI8PdGQIVv2fXHzAmESox6k/Gn2EGhPnSeCOQ5RcRUZ6DhA3ZI4xoOPEMcwQYFcyWMUo89rruPSYKycnWLVUh7jggATf2NJANKEkmiYkmnqw+BT7KkuwliwGOODDf/a4qsOv+URNB30oxD4LIPzeVg/Pm9i9seNZNXOmUK29Kwh3QNJf8t8su/DdoqdoEZMw3/i1w9r3GRo0AFf/ILjoVkPZG+AxQQolYFgJ348tTpAgEaFABTBUl/mJMxOysw5YUNLq1ylDxk/VJZyc+rjBy1ssbieHkmktLOU86NTNz1qSy9ItmQUwgOfw/tIOGuncRIDc7RzVFMFeG1WXcAXeHha5foq0K80Jn4pUSbqgBYi6AE14KISPefSJ4Dyhzw6iNpSYLMbFKmHhsT6GpvpIOG52nKXhlBBgTDGzRH32hNNNcgyRTCwtIV3hcDqUi8g4rkFEZqThDoIwOCcIOMvdLEsFSWRJFGDpkMDUHMLUjoFdIHqIqTO4fG0UdFKfyvUBofQwby/8Gt3AZPlw/Ffy671JBIeoyTorUMn+DQCAxyV/rC4vn+7t9zRmins9iNRv9cwd9kF9GHiWGAfIa8uJP3ZmoKex3rxCQ26KwLi/4zWr8J5obgXyx1sniTjC2DhABBJK4lCJX5ElWkviAva46KLh9vdu/wPzkkOafgoVVb+k3mcV5nziEhgbBP2bHHp3A+KpXNC7+lkAwf1j94Xb+OpBHzd7XN3Dh7z2UYJ7WGdx68TjOmc4oHx/GFbEP0n7FlT1XJKP4/oE3phGLNFTQ566RAhbSUBDMMGqBwvgNS5l6Q0obdx6h0ORdVmfsnnzv7lTFnktiQXYbvaRB5pwhvTzAHq6wO/VIVzi+rmdw/XhGYuXf3vhi84Fir2r4Co+L9bLvb1iUIR+lSBCsn4swVtP/RC932n693cm8WvtGTRperW4tx4ROoR3iPeBUMiZBH3RySfU+sQKn2Zm+EK2vzGtRSN+/WlNY9Yp3BqMoQ6wFA4L06J0pI5xWDXqDnpvIwfLEqwliQGs5H2IB3etC5SlWo8Fau5TuNQHetwLfermibv2Y95dEXqqV/D/PViMrR1TQ0neqN7/bY+vM5l+3p663H76rw0GM9+68j/q/VqynvPu+iQ71RMNr/sle++B5uP3t+Rie4cRa1wkSoJCAulz7geH1wDRa9TTvhSYRPY8loFEEz1HxZ2Fdx0zKElhI0oPK9O6GOQIksjHZZHX0iiADA6D8MnKCwjX/X3crx7xs8GjttGj7QHixO/3wq8hfHGLEPP8e+Mdwici9GWrv7x7dvRPn7l18453P5Dvee3b9Nt2gz+MdeVMv31PHz3HPvNEE+XLNaJGzxG8skxCGMrDSCiaqCeIhxVsOorJo8hSxxgfJ2xaZCicffy1ki9Q+suVZZ9tUGVJWEKLHgPs/Ud/u0fK9Vq/wE4bETqbNXzcW58NAP6NHT0x+TlJlNP4mKhf+SnCL9xN0I2bzKCetmXDI/5cFnDtf1rdtTbjt9t3eZl6hMgYwOMDZ+lxsZYAABAASURBVI4mDyOdoMa00fuOJboAengYVytE9bHx97FrOJGbWGIQLi96i+Lzwe/dtMvVsohr0S2Ahcov8UIPGzcI26I+z/AIJjXAn6BpODpIV8AYXd2Ee3lVadJ9NpAHdr7rPbYj6rJ5y4bhuaIzCw6iXL5J/6/d/PrzThtf2DTpIzWXnZMXCNhEzg4kpyozWggvxSsVNpFxohGRVcrkiaB4HVM7NrM7twWbhOkhz53MdcqiKsCiB4HmNw/jxZu9bi/Ot1Rn9aD4o8MyAXuKY/bOBkoO8RaHWPy6AW0wazn9I2oJUITma/ajHrhlwzbn9Nd5XKx11abBFVruenZq9dM07CViTwaIHseUsAs+nyppf02pjD4FdQVnR1JKkTMklItBMsGQU0W/Iwkl2r5YFnktqgvY879dtZOFSV/xHdzyOjypTgI9YOzVsQGhwP3anG3s7kAC8SVBrYupYGRXcHnqZ/+/dPa+O495oH5+Ide+p49PNSvwDpgAyp4ziD09lKCmBGVF+nkEOkdonyCCUI/AOHlR4qaEY0yca7Ln72/Y4TuySGtRLYD94w8nSIqGjxKesXrJlu3XRSc1gZ4PMFfwmcvxK327RAAoUUVjMPZXd62bOXI5hI/1hVMGv2ew3Vt9Ig1LgN5rSCDJU0dONKGo/WKjEpeeZZTI3ge3ABEekj4Ey9DReyaZVV3US9EuqgJYpH9UhEjOtCFaNmRbfQ3IJJA+maSBceUtQu3SzAWBfkkerT16Z8JPjxoe+ZU36qws47p80/ACC+ffyjqEc048bQ1XkD1cDCCKpQY2r3RRmkZrMvCCsU85pYvA+9ARBQpaTvUoWcS1aC5grwuv2LHI8B8U4xGYyw/ZZF+cxBGRP34xTvUQmWP0EmOlC0hh8kuKkr3DtrbZbrlzm+F+yy38+euAM8Zn2Zf7D7xODBvUYjylOizlpJJ+1hnj35L6xjb1JBjXoW98JFZcuNoRxXY0u9v7T91pUQZLLpoFqDo4UrwdAhx/595UL+M6J4C5fq06ZwEmeX9KnuJltn/HGTUolvrDphkeNU3Cx9r5qe1G+8rX1NITS50GLn5BaXcN4rc1JhSylU3UzT17h4gC8gKYTkZ1VRjNrH+ZLNJaNAWwci7Mfw3z7dF8mVfyDYo3hR6xQB9N1x4fCFg20MMw/s0rr30LAZupWh87VnN7X3OkgULfrUEr4dxBRoe8wqSWYCNzJJH6PGGJ/oRcHQsDkpjFM6BMohlb2hbNDSyKAuzxh7fsbMDO82JWHwexgCAlJHF5TOwJtDjKFzEdaTXRGCrirN4e4ROv375zy4bBpTKl62826/+zTf2KGj6/BBxNcpHb9CgzazAAImicHB/vSa4cJeBjqy0854Qz73m8LMJaHAug3ZGRt9cqPanDTT0Hc8V95/CFb69Nj9tLX82L6wFJ1HSvv+G+bc6UKV9XbtQv2Bc+hzudXlwnlxTCP74kdwMqc26BOsI55M5NwO7IdBsS2YNlA40cKYuwFkUBTLhHscwbNG+mejlas1DpU4dxJfiB3jkhMqkBiF9rzREgoGhltmvKcbJZi6yA9cP70tvt3/L3k05C5xIEiFW1HyuTw/zzouTBaWQmIDEpLadQCF4CcVHcwIIrwK4X3vAEc3y/6DAtTL2PVqrxD3S17rvzJiaeVULt/b5GgZ7CB0DUbrjppHVflxWybt2sI/vH/RpDmAgCWZ2k+U+Eg2tcbxxpohPLJJSkjxP8eboFRQubPueYc+99gizwWnAF0K59pbN32lp6DMAL61Hs8f5cp805PCw6oXA5O4PduX1mINd+ceNwKiZrP5h1xUb9kgnyDOlJySIcLiHEAGjmAzJ25Lo0RJVp4mqUNzwQlGhPY/r8SlngtbAKsBmkKz3eo383+dqbeWF0E+PdA/jhrldn3cz7KtGmhR2DgXC/KSt0PfIR6V22i79BW1YcICrcz31Q60JGuRjjZwozBXH3kLyalNmI6vUQyyxOcOh04daCKsDuO3zpRVbNeqwLM+r6HtayEbTn+M/V7n2pj9yR3k2IRhe/ytlfPGWbr8gKXZ82rKJJ8hrubHXquESKGHKMhkX3+X4hDI5BUY6YTp4hVZ9QjDhh56PO6RaUL7iwFkDbV3lQh9Ko8+F9wPL86F6dPxVB33zBu9vXPiD8xg0bZt4pK3xddrJ+3n6MixwGVI6mJ6PQWexRMyjqRNiekO6egZbBa2EESr2iWo6XBVwLpgBPu/ArOypGnyYf2uzDVlKkeQ6MslJb/Wog6nWwSafvhI7p98DM3SirZNVxOskCgG5Syk49pWQyvyLSG6eiE/XEEIkanU4ZHBI2l6M7/tAjzq87yQKtBVOAdlx/U9hp69fc7i/f6o2bMdgxO6Ln/8AI+ELkXirz+6Yk19ywsf2ErJJ15dv0Hw3kP59ooJIQ5EXiKBREv0qf4/IKBN5a5sc6b8AxFAZQ4+7VskBrYRRgM4efHNdPVK99G1d20rN35gRRIxojwuQHhVvm0ECahZk3yipb3b1yugn7DkcFhTOJavz7Ufj3WQMOHvUkE595G97S/9SnnspvLVQwuCAKsOsOXz2k1sFj4e/9et9+XT8HwXraVlC++scaaJ8QEuPQBiKF2nzk+o16o6yydfVmvTNLeWffllY82/WLHVb3j4QGAgbmBPwIHNmfSC6k+gW1VHY+YoGCwQVRAKts/I7/i7zCx13eB32TOMAVVie7XyetXYwZEnv+7x2m5lRZpevJu6T32c74aq8Enu9LT2H3aWURD7BApE4KqREAFl4pQ6N9TE6UBVgPWwF2+/2v7Gdmfu9Jxy5LvE2AQDEDYF7e72PcNFqpdEKoi6jx7GtP1mW/ktZiLVQM7Wc50eO82PEUfiQ+bglIiSi9QgThmZPukhPIvAlFDnzpGaNnycNcD98CdOlNk6qdekNHb/a9q3ZeRS8i3NRz/ueQANz7zj3r2/Nkla+rTtLP2e9wSV/68DI4X1Lf5VE46mlTIp4e8m5lSkj3ANpY22ySh7kelgLsev5Xn2M7/rn074HvS5j2xES3z+/7GGBO4P7/OeUwv/eWaSN5LNYaV3mLBHc56G/ady0SJOLP5imgX/LIMQIPDOe5ilIPOeRhWoGHpQBJmzdF6daDvuKj1xzta+okq5f+9l8oQdxaGPDXN21sPyVbybruFP2a7fLfm+xwcVPQX7aylH6wiTeRC2eLaQBDGjB5YGbJMIaHsR6yAjz9nG/sbd9g3+RTdb3nD5SvHAhHBIF+mWadQwIdDtK5wFDvs2rf62QrW49+hKWFol/vHxeZ2y3BISG30JFC3/IxuJR60xEyZuh02MFn193lIa6HrABtSm+Mnn0v5ZAI6WkqGz9jx9e4TNuE5DHx/e4TrND5rus36G2ylS3UCczyvabqHGZWexSQ0b+niMXRQfpRWoHqAaLTy3nhcysi5ZPlIa6HpAC7nf/tvey7PV97/+7ovri/nysBT8w+b2JKZ5C6wxrceP2G4QWyla4rrU5gUv8w7vcmvUzaCT0G0B5QrX2yFMVTn0rF1+0tRx10Tt1VHsJ6SApgqvkOXlqz9OZ93g6XueBuTrUZ3ugkE3B6N2ZsvV628lVKeqsJ/PYQbO1BMloG6f9COSI99GmEHiH2DSe1yw+JLvegFWC3c795rH2PZ85H+Nzkp3mnnFgA7S2CM99lbjiLyLtu3jhzs2zl65pNhIePd6vIWeSx3QPwyT2rKMAC6ZXDrUWh9eV8pANfeFb3oLuIHpQC7PaB27czQZ8kfbmHbrzptTac2aSzd+6NgW7MwwNuvOFH7dmytriuPkX/0nbyf58YTHSM+OXQ5jJl8dtc+2JSYAg8zi2CoUxn7nNuXScPYj0oBUh3/ehUU4Dt/CJJGuMRwqeT8ZCc05Q8JtDa7/YeBu7HrbS/sVIInku1BipvtvTu2720kSyVwIJK/7v1WQAPkN49aPYiAoKHnWe68qDAoQesALuf/a1DzNQfrnEJ96B3iX/h3gWEQvS1TvVaQPyL4tj6ppstD5a19WPrig16V0r6KmePz+WE/Xi73nrWcKGTZlNOpXVmkWME8oYD3zM+4IF+7gNSgF3P/vvHaGo2O9IXs/rw4cU5/iHcOh/Z6/2V9rO3XHc/deMpwz+StfUT11Un6xX2u73N+wTCIQRjPk+AoqpBLvXk0GNBJ9nWfnpKunC/M+t2D+QzH5ACpLTte0zoj+jr+hOWTwR7kzBVJKqA4RgmfX38cl9eNzP4bVlbP3Ndc4paNF8/3Y+vqz04EEa1BqhWoz5QQggsE6eAiqvu1NT8Bw/k8/5VBXjGef94sgl174B3g9OvTuvQPv9v+tw+LtGqEcUG5afoPbXJx/2kyVxr6/7rnlk9zn65b/aXK6mBEYE1Ij0M7FDAxLW6BegtAWcnH3rAWflN/9pn/UwFeMbZtx1pn3Is0jz3+OnHzTyDkmZyqn7wemQA6uQwu0362yupsWO5162b9W4r9h1tP999fUjImxQNtDqXGvrlq10eOTqRsmsEWu83Pv/08c+cN/hTFeAXLvj+swynPcXn1nlw57P44pItHoHw2MlQ5j506blM7vfffv3G9tOyth7Uuvokvcl+xldWZ9Pyucrp5U4Zj7JKPC9hbSXSQpYJFHMKS2ouPOC0usdP+5yfqAB7nfW9HevYkKXq19hQ6SP+voffmftzp+gBIR+qKzIZ0f4nFvQ9IF+0tu6/rt2oViHVN0gI2EWs8zZj0INkghjHtQmDgu2zj9fbo4+94Nz69J/0GfdTgKdf8A8z41TOsp2/vQ93SJOALwX0gzyfY9Em5t8HMIv2p2T+99c3zrZvlrX1sJYFhe+3X/S9zhvQCUHUU8QoElZ3A95u5jEA2cR4jaxieVQ3qh9//ln37fIvz38/BRjmbTeacHdRvxyKTzOYTOjsGzxjztv8NE8i8ifNS66/e/3g1Wtgz8KsqzfqSfar/vm8KloUjSZUrAlHAOY/96kCHmKPklhaH5PzzMf2O+PeJ88/948pwO7nfu+1dp4X9P36cyXc8PN9e4rtfA5JdFQiUj6fiGXf7JZG7zp2a2H3LNUqP2fxQK2XTCB1DUKxhP/v/QOvdetTapxvGGUYtxCPLbrNH7/gvPqo/rwTBXjGed99Sar12JTdfysbuuaBPrUHdeOafdmtfp92+ChX+UI7GhyxZePP/ZOsrQVdW16n4+s2pZfZb/0RH0MoPmY5iDUlgkWohiOJdZI8OIeAdDN0mT5lNKof7M9LBdj9vO/+vJn635F50C4vt+bYs6d6gftjaYxCg1ol7aHg+rnxbHvcls16r6ytRVvXbpQT7Mf+ryWQv0m/TfVYoMcI6iRejw3KodRBRy+y1z5nVlLJKNE9zvnuf1Zpd+E4zppav1ZaavyyN7j8cfLh/pxmmxoQ/7y3G3OvWYr6xI2bhquum2ea1787vf4nkzGE6FPxhReviLjPh/JUz8ty9ate+iVL2W4AyKBQBBmKAAABz0lEQVSW0qWD057nfv9gixSePJmEToDHyV2xuwPdE3cJ3t0agQcYSXLamvCXfm05Rd9uEnu5/d3Z22WvCdQJ5wKrBJWoTyMDKBKOpG/Lsdi9L07Fx7NUb1t1dy8e+TulI9yChPl3RbnN1OFlN67A6R2rZV23UT9h2/nfmuAv87qBOJtIHCGMUEDnEwqqxLQ9HpwONMc+eHwP5CT1qfUMAznQMtpXe2BHo0JR9LPjHw0OvX7jcNX18K20BULtlk36Ytu6bKmr4smZz6rsiVg1rrLugWENK15qeVxKRX4g/XV4Yh6v7/ZGYqaJxmQLDLIe2Sk2m8k/EXi1rK2pWYYavtekvI9J97oejO8zAwdjomYvfqFbTx7SDRbqDy7WCYQTbL15/Xq80p7DfFeUlF960ykzH5W1NZXrulP1lus26QtN4q83z30bc7ri81l6BNE7jv3aRqrj8ynoZ5157y8Vya+xgP9pHvmLD/RRvdmcyZaayiU3bVi/aDPr19bCr0MvqDO33yvHm7SfbfnBk+yppxqwvz2ajs0EXG253Xuu2UBrMbeeeXp9dE2jnUvN99y6cf23ZG2t+qWytrbq9fDbw9fWil5rCrCVrzUF2MrXPwMAAP//z9dnYQAAAAZJREFUAwCDZN+FnSK/OAAAAABJRU5ErkJggg=="; export const AntigravityIcon: Icon = (props) => ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4b53c14c3ed5..1991dc502283 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -17,6 +17,7 @@ import { prStatusIndicator, PrStatusTooltipContent, terminalStatusFromRunningIds, + synchronizeTerminalPulse, ThreadStatusLabel, ThreadWorktreeIndicator, useLinkedThreadPullRequest, @@ -832,7 +833,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } > {terminalStatus.label} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9f73f09eff68..7cbae330c318 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -205,6 +205,7 @@ import { prStatusIndicator, resolveThreadPullRequestBadge, terminalStatusFromRunningIds, + synchronizeTerminalPulse, type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -1400,6 +1401,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + // Background work fades as a whole row, status label included, so it + // takes less attention than rows that need a human (input, approval). + shouldRecede && + (status === "working" || status === "monitoring") && + "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", isFileDragOver && "ring-1 ring-inset ring-primary/70", // The hover tint must not clobber an active/selected row's own surface. isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", @@ -1513,7 +1519,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-testid={`sidebar-terminal-status-${thread.id}`} className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)} > - + ) : null; // Same pen the new-thread draft rows lead with, so both kinds of unsent @@ -2154,6 +2163,7 @@ export default function Sidebar() { confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, + setThreadAutoSettle, archiveThread, deleteThread, } = useThreadActions(); @@ -4020,6 +4030,9 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; + const supportsAutoSettleOptOut = + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadAutoSettleOptOut === true; const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; @@ -4049,6 +4062,7 @@ export default function Sidebar() { : null, isPinned, isSettled, + autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, @@ -4056,6 +4070,7 @@ export default function Sidebar() { thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, + autoSettleOptOut: supportsAutoSettleOptOut, snooze: supportsSnooze, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, @@ -4127,6 +4142,24 @@ export default function Sidebar() { case "unpin": attemptUnpin(threadRef); return; + case "auto-settle:enabled": + case "auto-settle:disabled": { + const result = await setThreadAutoSettle( + threadRef, + clicked.value === "auto-settle:enabled", + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update auto-settle", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "rename": startThreadRename(threadRef, thread.title); return; @@ -4253,6 +4286,7 @@ export default function Sidebar() { projectByKey, serverConfigs, setProjectScopeKey, + setThreadAutoSettle, startThreadRename, updateThreadMetadata, timestampFormat, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 60b17b19aa6b..5406cba08c0d 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,14 +1,31 @@ import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import type { AnimationEvent } from "react"; import { ChangeRequestStatusIcon, prStatusIndicator, resolveThreadPullRequestBadgePresentation, + synchronizeTerminalPulse, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +describe("synchronizeTerminalPulse", () => { + it("pins only the status pulse to the document clock", () => { + const pulse = { animationName: "status-pulse", startTime: 975 } as CSSAnimation; + const otherCss = { animationName: "other-animation", startTime: 125 } as CSSAnimation; + const otherAnimation = { startTime: 250 } as Animation; + + synchronizeTerminalPulse({ + animationName: "status-pulse", + currentTarget: { getAnimations: () => [pulse, otherCss, otherAnimation] }, + } as AnimationEvent); + + expect([pulse.startTime, otherCss.startTime, otherAnimation.startTime]).toEqual([0, 125, 250]); + }); +}); + describe("ChangeRequestStatusIcon", () => { it.each([ ["open", "open", false, PullRequestGlyph.pullRequest], diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index b8d8a00ae8bb..33e5c491229b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -16,7 +16,7 @@ import { } from "@t3tools/shared/threadPullRequests"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useRender } from "@base-ui/react/use-render"; -import { useMemo, type MouseEvent, type ReactElement } from "react"; +import { useMemo, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -394,6 +394,17 @@ export function terminalStatusFromRunningIds( }; } +/** Align newly started pulses with the document clock without a timer or frame loop. */ +export function synchronizeTerminalPulse(event: AnimationEvent) { + if (event.animationName !== "status-pulse") return; + + for (const animation of event.currentTarget.getAnimations()) { + if ("animationName" in animation && animation.animationName === "status-pulse") { + animation.startTime = 0; + } + } +} + export function ThreadWorktreeIndicator({ thread, }: { @@ -582,7 +593,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma } > {terminalStatus.label} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index b34b8a60b12f..b155421572e0 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1447,7 +1447,7 @@ export default function ThreadTerminalDrawer({ event.preventDefault()} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index c1f448a73ea8..76ea907b84cd 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -215,7 +215,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
- {showPhone ? ( + {showPhone && isDuo && model ? ( + + ) : showPhone ? ( setPresentation("phone")} > 3D diff --git a/apps/web/src/components/device/deviceModels.ts b/apps/web/src/components/device/deviceModels.ts index 682e0e6de4d5..d9197a27e357 100644 --- a/apps/web/src/components/device/deviceModels.ts +++ b/apps/web/src/components/device/deviceModels.ts @@ -4,6 +4,7 @@ import { type DeviceModelSource, } from "@t3tools/client-runtime/device/model"; import type { DevicePlatform } from "@t3tools/contracts"; +import iphoneDuo from "./models/iphone-duo.glb?url"; import iphone18Pro from "./models/iphone-18-pro.glb?url"; import iphone18ProMax from "./models/iphone-18-pro-max.glb?url"; import magicKeyboard from "./models/ipad-pro-13-m5-magic-keyboard.glb?url"; @@ -11,6 +12,7 @@ import ipadPro13M5 from "./models/ipad-pro-13-m5.glb?url"; // Bundled URLs follow the client origin in local, desktop, hosted and remote sessions. const models: Record = { + "iphone-duo": { id: "iphone-duo", url: iphoneDuo }, "iphone-18-pro": { id: "iphone-18-pro", url: iphone18Pro }, "iphone-18-pro-max": { id: "iphone-18-pro-max", url: iphone18ProMax }, "ipad-pro-13-m5": { id: "ipad-pro-13-m5", url: ipadPro13M5 }, diff --git a/apps/web/src/components/device/models/iphone-duo.glb b/apps/web/src/components/device/models/iphone-duo.glb new file mode 100644 index 000000000000..0269a1dc40e0 Binary files /dev/null and b/apps/web/src/components/device/models/iphone-duo.glb differ diff --git a/apps/web/src/components/device/models/sources.json b/apps/web/src/components/device/models/sources.json index 513a56fc291d..766b65a7a759 100644 --- a/apps/web/src/components/device/models/sources.json +++ b/apps/web/src/components/device/models/sources.json @@ -6,7 +6,7 @@ "gltfTransform": "4.2.1", "changes": [ "Extracted device bodies and a separate Magic Keyboard accessory; excluded unrelated parts and presentation duplicates.", - "Normalized portrait axes, centered the screen and uniformly scaled its height to 2.2 scene units.", + "Normalized conventional device portrait axes, centered their screen and uniformly scaled its height to 2.2 scene units. The Duo retains its upstream centimeter hinge rig.", "Replaced the baked marketing screen with a named placeholder for the live framebuffer.", "Resized textures to at most 1024 pixels and encoded them as WebP; retained body geometry." ] @@ -44,6 +44,14 @@ "bodyNode": "zRrSLDpdYmKeRJQ", "screenNode": "lsDiIbtoSGSmWWZ", "accessoryNode": "PoBqSMmyhhcJsBX" + }, + { + "id": "iphone-duo", + "file": "iphone-duo.glb", + "sourceUrl": "https://www.apple.com/105/media/us/iphone-duo/2026/9305e4b9-72d9-4c05-9381-b572adadd5e5/ar/iPhone_Duo_e-sim_Star-White_Variant.usdz", + "sourceSha256": "5cab2ea636da0bc0b06c8abf4809843498f7c1d680042b4f95e383b5c6a718b2", + "upstreamConversionCommit": "bb265b11c13b395e5302d121458e2d42224a2e9f", + "conversion": "Preserved the centimeter split hinge rig and three display meshes from serve-sim; resized textures to 1024 pixels and encoded them as WebP without simplifying or flattening the rig." } ] } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index f1d028a3d2c1..84d0a966880d 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -287,7 +287,7 @@ export function AddProviderInstanceDialog({ value={option.value} disabled className={cn( - "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5", + "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-64 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5", )} > diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 8fe70c975f77..0794553cb8fb 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -427,7 +427,7 @@ export function SettingsRow({ tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" className={cn( - "@container/settings-row rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + "@container/settings-row rounded-xl px-3 sm:px-4 aria-disabled:opacity-64 aria-disabled:[&_*]:text-muted-foreground", children ? "pt-3 pb-1" : "py-3", className, )} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index a37aba27b8de..af4bd8a1f5cb 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,7 +1,7 @@ import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -24,6 +24,7 @@ import { } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; +import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -127,19 +128,10 @@ function SidebarUtilityItem({ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); - const canGoBack = useCanGoBack(); + const navigateToMainApp = useNavigateToMainApp(); const { isMobile, setOpenMobile } = useSidebar(); - const currentFooterPage = useLocation({ - select: (location) => - /^\/settings(?:\/|$)/.test(location.pathname) - ? "settings" - : /^\/projects\/[^/]+\/?$/.test(location.pathname) - ? "project-settings" - : location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + const isOnUtilityPage = useLocation({ + select: (location) => isSidebarUtilityPage(location.pathname), }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -173,16 +165,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const handleBackClick = useCallback(() => { closeMobileSidebar(); - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, closeMobileSidebar, navigate]); + void navigateToMainApp(); + }, [closeMobileSidebar, navigateToMainApp]); return ( - {currentFooterPage ? ( + {isOnUtilityPage ? ( diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts new file mode 100644 index 000000000000..fcbf2f489d33 --- /dev/null +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -0,0 +1,36 @@ +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect } from "react"; + +// Settings, Usage, and Pull Requests replace the sidebar utility row with a +// Back button. Everything else is the main app. Legacy `/projects/` links +// redirect into settings, so they count too and are never remembered. +export function isSidebarUtilityPage(pathname: string) { + return ( + pathname === "/settings" || + pathname.startsWith("/settings/") || + pathname.startsWith("/projects/") || + pathname === "/usage" || + pathname === "/pull-requests" + ); +} + +let mainAppHref: string | null = null; + +// Mount once in the app shell. Records the latest main app URL so Back can +// return there no matter how many utility pages were visited since. +export function MainAppLocationTracker() { + const href = useLocation({ + select: (location) => (isSidebarUtilityPage(location.pathname) ? null : location.href), + }); + useEffect(() => { + if (href !== null) mainAppHref = href; + }, [href]); + return null; +} + +// Leaves a utility page for the last main app URL, or the thread list when +// the app was opened directly on a utility page. +export function useNavigateToMainApp() { + const navigate = useNavigate(); + return useCallback(() => navigate({ href: mainAppHref ?? "/" }), [navigate]); +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 14965d4add58..c5ebeda55627 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -7,11 +7,18 @@ const baseState: ThreadActionMenuState = { projectFilter: null, isPinned: false, isSettled: false, + autoSettleEnabled: true, isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, isRunning: false, - supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, + supports: { + settlement: true, + autoSettleOptOut: true, + snooze: true, + pinning: true, + titleRegeneration: true, + }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, ], @@ -32,7 +39,13 @@ describe("buildThreadActionMenuItems", () => { expect( ids({ ...baseState, - supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + supports: { + settlement: false, + autoSettleOptOut: false, + snooze: false, + pinning: false, + titleRegeneration: false, + }, }), ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); }); @@ -66,7 +79,7 @@ describe("buildThreadActionMenuItems", () => { const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project"); expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" }); expect(items[filterIndex - 1]?.id).toBe("mark-unread"); - expect(items[filterIndex + 1]?.id).toBe("copy"); + expect(items[filterIndex + 1]?.id).toBe("auto-settle"); }); it("includes branch items only for threads with a branch", () => { @@ -84,6 +97,25 @@ describe("buildThreadActionMenuItems", () => { expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"])); }); + it("offers auto-settle as a submenu with the current option checked", () => { + const find = (state: ThreadActionMenuState) => + buildThreadActionMenuItems(state).find((item) => item.id === "auto-settle"); + const on = find(baseState); + expect(on?.label).toBe("Auto-settle behavior"); + expect(on?.children?.map((child) => [child.id, child.checked])).toEqual([ + ["auto-settle:enabled", true], + ["auto-settle:disabled", false], + ]); + const off = find({ ...baseState, autoSettleEnabled: false }); + expect(off?.children?.map((child) => child.checked)).toEqual([false, true]); + // Sits with the per-thread settings after Mark unread, not the lifecycle verbs. + const items = buildThreadActionMenuItems(baseState); + expect(items[items.findIndex((item) => item.id === "mark-unread") + 1]?.id).toBe("auto-settle"); + expect( + ids({ ...baseState, supports: { ...baseState.supports, autoSettleOptOut: false } }), + ).not.toContain("auto-settle"); + }); + it("disables snooze when the thread cannot snooze, keeping presets visible", () => { const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( (item) => item.id === "snooze", @@ -117,7 +149,13 @@ describe("buildThreadActionMenuItems", () => { expect( ids({ ...baseState, - supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + supports: { + settlement: false, + autoSettleOptOut: false, + snooze: false, + pinning: false, + titleRegeneration: false, + }, }), ).toContain("archive"); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index c37ec31f929c..838fc91a636c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -14,6 +14,9 @@ export type ThreadActionMenuId = | "unpin" | "settle" | "unsettle" + | "auto-settle" + | "auto-settle:enabled" + | "auto-settle:disabled" | "snooze" | `snooze:${string}` | "unsnooze" @@ -40,6 +43,8 @@ export interface ThreadActionMenuState { } | null; readonly isPinned: boolean; readonly isSettled: boolean; + /** False while the user has turned automatic settlement off for this thread. */ + readonly autoSettleEnabled: boolean; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; @@ -47,6 +52,8 @@ export interface ThreadActionMenuState { readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; + /** Server understands thread.auto-settle.set. */ + readonly autoSettleOptOut: boolean; readonly snooze: boolean; readonly pinning: boolean; readonly titleRegeneration: boolean; @@ -131,6 +138,31 @@ export function buildThreadActionMenuItems( }, ] : []), + // A submenu with the current option checked, not a one-shot action: + // this is a setting, and it sits with the other per-thread settings + // rather than the lifecycle verbs above. Disabled keeps long-running + // threads out of the settled shelf no matter how quiet they get. + ...(state.supports.autoSettleOptOut + ? [ + { + id: "auto-settle" as const, + label: "Auto-settle behavior", + icon: "timer", + children: [ + { + id: "auto-settle:enabled" as const, + label: "Enabled", + checked: state.autoSettleEnabled, + }, + { + id: "auto-settle:disabled" as const, + label: "Disabled", + checked: !state.autoSettleEnabled, + }, + ], + }, + ] + : []), { id: "copy", label: "Copy", diff --git a/apps/web/src/components/ui/discovery-list.tsx b/apps/web/src/components/ui/discovery-list.tsx index d03ca0edd5e0..70af3c8418bd 100644 --- a/apps/web/src/components/ui/discovery-list.tsx +++ b/apps/web/src/components/ui/discovery-list.tsx @@ -24,7 +24,7 @@ export function DiscoveryListRow({