diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index cb14a3127a2a..43be8f59572a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -52,6 +52,7 @@ import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleH import IconEdit from "@tabler/icons-react-native/IconEdit"; import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; import IconEye from "@tabler/icons-react-native/IconEye"; +import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; import IconFileText from "@tabler/icons-react-native/IconFileText"; import IconFilter from "@tabler/icons-react-native/IconFilter"; import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; @@ -165,6 +166,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "exclamationmark.triangle": IconAlertTriangle, "exclamationmark.circle": IconAlertCircle, eye: IconEye, + "eye.slash": IconEyeOff, folder: IconFolder, "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index 2f9d14e7ce22..667e1383ea97 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -6,7 +6,7 @@ import { useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, type GreenPullRequestSnooze } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -17,6 +17,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { AndroidSettingsEnvironmentFilter, @@ -55,6 +56,23 @@ export function SettingsThreadsRouteScreen() { } const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; +const GREEN_PULL_REQUEST_SNOOZE_CHOICES: ReadonlyArray<{ + readonly value: GreenPullRequestSnooze | null; + readonly label: string; + readonly description: string; +}> = [ + { value: null, label: "Off", description: "Leave the thread active when checks pass." }, + { + value: "indefinitely", + label: "Until something changes", + description: "Wake if a check fails or review feedback arrives.", + }, + { value: 1, label: "1 hour", description: "Wake after 1 hour." }, + { value: 3, label: "3 hours", description: "Wake after 3 hours." }, + { value: 24, label: "1 day", description: "Wake after 1 day." }, + { value: 72, label: "3 days", description: "Wake after 3 days." }, + { value: 168, label: "1 week", description: "Wake after 1 week." }, +]; /** * Mobile edits auto-settle defaults across selected capable targets. @@ -91,6 +109,7 @@ function AutoSettleSettingsRows() { patch: Partial & { autoResumeLimitedThreads?: boolean; snoozeLimitedThreads?: boolean; + snoozeGreenPullRequests?: GreenPullRequestSnooze | null; }, ) => { if (writeInFlight.current) return; @@ -129,6 +148,17 @@ function AutoSettleSettingsRows() { target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, ); const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); + const supportsPullRequestWatchAutoSnooze = + displayTargets.length > 0 && + displayTargets.every( + (target) => + target.environment.serverConfig.environment.capabilities.pullRequestWatchAutoSnooze === + true, + ); + const mixedSnoozeGreenPullRequests = displayTargets.some( + (target) => + target.settings.snoozeGreenPullRequests !== referenceSettings.snoozeGreenPullRequests, + ); const hasProjectOverrides = projectSelected && syncTargets.some( @@ -171,22 +201,52 @@ function AutoSettleSettingsRows() { /> ) : null} {!projectSelected ? ( - - writeToAll({ autoResumeLimitedThreads: value })} - /> - writeToAll({ snoozeLimitedThreads: value })} - /> - + <> + + writeToAll({ autoResumeLimitedThreads: value })} + /> + writeToAll({ snoozeLimitedThreads: value })} + /> + + {supportsPullRequestWatchAutoSnooze ? ( + Mixed + ) : null + } + > + {GREEN_PULL_REQUEST_SNOOZE_CHOICES.map((choice, index) => ( + 0} + disabled={disabled} + onPress={() => writeToAll({ snoozeGreenPullRequests: choice.value })} + /> + ))} + + A thread wakes early if a check later fails or review feedback arrives. + + + ) : null} + ) : null} = Date.parse(thread.pullRequestSnooze?.wokeAt ?? "")); // Settled rows label by the same stamp they sort by, so order and label // can't disagree. updatedAt is always present, so the resolver never // returns null here. @@ -881,17 +892,27 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { type="monochrome" /> ) : null} - - {statusLabel?.label ?? timeLabel} - + {isPullRequestWoke ? ( + + ) : ( + + {statusLabel?.label ?? timeLabel} + + )} { close(); onSelectThread(thread); @@ -1131,21 +1170,56 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} {props.hasQueuedMessages ? : null} - - {snoozedRow && props.snoozeWakeLabelText !== undefined - ? props.snoozeWakeLabelText - : timeLabel} - + {isPullRequestWoke ? ( + + ) : watchedPullRequest !== null ? ( + + + {props.snoozeWakeLabelText !== "∞" ? ( + + {props.snoozeWakeLabelText} + + ) : null} + + ) : ( + + {snoozedRow && props.snoozeWakeLabelText !== undefined + ? props.snoozeWakeLabelText + : timeLabel} + + )} ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5d69458ba520..735992cba229 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -242,6 +242,7 @@ export const make = Effect.gen(function* () { threadPullRequests: true, pullRequestStackActions: true, pullRequestWatches: true, + pullRequestWatchAutoSnooze: true, threadPullRequestLinking: true, serverResolvedCommandContext: true, environmentIcon: true, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b5af88703fb3..eebf9498b816 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2440,6 +2440,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, snoozedUntil, limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null, + pullRequestSnooze: + command.pullRequest === undefined + ? null + : { ...command.pullRequest, wokeAt: null, wakeReasons: [] }, snoozedAt: existingSnoozedAt ?? now, updatedAt: existingSnoozedAt === null ? now : thread.updatedAt, }; @@ -2450,6 +2454,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, snoozedUntil: null, snoozedAt: null, + pullRequestSnooze: + command.reason === "pull-request" && thread.pullRequestSnooze != null && !alreadyAwake + ? { + ...thread.pullRequestSnooze, + wokeAt: DateTime.formatIso(now), + wakeReasons: command.wakeReasons ?? [], + } + : null, updatedAt: alreadyAwake ? thread.updatedAt : now, }; } @@ -2473,6 +2485,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio settledAt: thread.settledOverride === "settled" ? null : thread.settledAt, snoozedUntil: null, snoozedAt: null, + pullRequestSnooze: null, updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now, }; } @@ -2538,6 +2551,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // Recovery changes acknowledge the same stopped run; keep its // metadata timestamp from appearing as a fresh failure wake. snoozedAt: now, + pullRequestSnooze: null, } : command.limitRecovery !== undefined && thread.limitRecovery?.snooze && @@ -4048,6 +4062,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...projection.thread, snoozedUntil: null, snoozedAt: null, + pullRequestSnooze: null, updatedAt: now, }; yield* emit( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 6818dcc18057..6f267e6aa005 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1351,6 +1351,7 @@ export function threadShellFromProjection( lastVisitedAt: projection.thread.lastVisitedAt, titleRegeneration: projection.thread.titleRegeneration ?? null, limitRecovery: projection.thread.limitRecovery ?? null, + pullRequestSnooze: projection.thread.pullRequestSnooze ?? null, deletedAt: projection.thread.deletedAt, }; } @@ -1573,6 +1574,7 @@ function shellFromState(input: { lastVisitedAt: input.state.thread.lastVisitedAt, titleRegeneration: input.state.thread.titleRegeneration ?? null, limitRecovery: input.state.thread.limitRecovery ?? null, + pullRequestSnooze: input.state.thread.pullRequestSnooze ?? null, deletedAt: input.state.thread.deletedAt, }; } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b55d63037ead..3d2e6a8a3a52 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1768,6 +1768,72 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("records which pull request woke an auto-snoozed thread", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-pull-request-snooze-thread"); + const pullRequest = { + repository: "owner/repo", + number: 13, + url: "https://github.com/owner/repo/pull/13", + }; + const snooze = (id: string, withPullRequest: boolean) => + orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`runtime-layer-pull-request-snooze-${id}`), + threadId, + snoozedUntil: "2999-01-01T00:00:00.000Z", + ...(withPullRequest ? { pullRequest } : {}), + }); + const shell = () => + orchestrator.getThreadShell(threadId).pipe(Effect.map((thread) => thread!)); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-pull-request-snooze-create"), + threadId, + projectId: ProjectId.make("runtime-layer-pull-request-snooze-project"), + title: "Pull request snooze thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* snooze("auto", true); + assert.deepEqual((yield* shell()).pullRequestSnooze, { + ...pullRequest, + wokeAt: null, + wakeReasons: [], + }); + + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-layer-pull-request-snooze-wake"), + threadId, + reason: "pull-request", + wakeReasons: ["review_feedback"], + }); + const woke = (yield* shell()).pullRequestSnooze; + assert.isNull((yield* shell()).snoozedUntil); + assert.deepEqual(woke?.wakeReasons, ["review_feedback"]); + assert.isNotNull(woke?.wokeAt); + + yield* snooze("manual", false); + assert.isNull((yield* shell()).pullRequestSnooze); + yield* snooze("auto-again", true); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-layer-pull-request-snooze-user-wake"), + threadId, + reason: "user", + }); + assert.isNull((yield* shell()).pullRequestSnooze); + }), + ); + it.effect("keeps the branch pull request when linking another pull request", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index dab6e49ddc02..c6762e68a21c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -56,6 +56,10 @@ import { workerLive as pullRequestWatchWorkerLive, } from "../pullRequest/PullRequestWatchService.ts"; import { layer as pullRequestWatchObserverLayer } from "../pullRequest/PullRequestWatchObservation.ts"; +import { + layer as pullRequestAutoSnoozeLayer, + workerLive as pullRequestAutoSnoozeWorkerLive, +} from "../pullRequest/PullRequestAutoSnooze.ts"; const runtimePolicyProvided = runtimePolicyLayerFromProjectRepository.pipe( Layer.provide(ProjectionProjectRepositoryLive), @@ -250,13 +254,23 @@ const scheduledTaskProvided = scheduledTaskServiceLayer.pipe( // the GitHub CLI (provided by the server composition, like SqlClient), while // the service itself hangs off thread management. The worker registers on // the shared scheduler clock. +const pullRequestWatchObserverProvided = pullRequestWatchObserverLayer.pipe( + Layer.provide(ProjectionProjectRepositoryLive), +); +const pullRequestAutoSnoozeProvided = pullRequestAutoSnoozeLayer.pipe( + Layer.provide(Layer.mergeAll(pullRequestWatchObserverProvided, threadManagementProvided)), +); const pullRequestWatchProvided = pullRequestWatchServiceLayer.pipe( - Layer.provide(pullRequestWatchObserverLayer.pipe(Layer.provide(ProjectionProjectRepositoryLive))), + Layer.provide(pullRequestWatchObserverProvided), + Layer.provide(pullRequestAutoSnoozeProvided), Layer.provide(Layer.mergeAll(threadManagementProvided, eventSinkProvided, idAllocatorLayer)), ); const pullRequestWatchWorkerProvided = pullRequestWatchWorkerLive.pipe( Layer.provide(pullRequestWatchProvided), ); +const pullRequestAutoSnoozeWorkerProvided = pullRequestAutoSnoozeWorkerLive.pipe( + Layer.provide(pullRequestAutoSnoozeProvided), +); const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe( Layer.provide( Layer.mergeAll(providerContinuationRequestsLayer, threadManagementProvided, idAllocatorLayer), @@ -315,6 +329,7 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( scheduledTaskProvided, pullRequestWatchProvided, pullRequestWatchWorkerProvided, + pullRequestAutoSnoozeWorkerProvided, UsageLimitRecoveryWorker.workerLive.pipe( Layer.provide(Layer.mergeAll(projectionStoreLayer, threadManagementProvided)), ), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 41598f33d498..e370518d4a80 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,7 @@ import Migration0054 from "./Migrations/054_OrchestrationV2.ts"; import Migration0055 from "./Migrations/055_RemoveRedundantProjectionIndexes.ts"; import Migration0056 from "./Migrations/056_PullRequestWatches.ts"; import Migration0057 from "./Migrations/057_PullRequestWatchCoalescing.ts"; +import Migration0058 from "./Migrations/058_PullRequestAutoSnoozes.ts"; /** * Migration loader with all migrations defined inline. @@ -142,6 +143,7 @@ export const migrationEntries = [ [55, "RemoveRedundantProjectionIndexes", Migration0055], [56, "PullRequestWatches", Migration0056], [57, "PullRequestWatchCoalescing", Migration0057], + [58, "PullRequestAutoSnoozes", Migration0058], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/058_PullRequestAutoSnoozes.ts b/apps/server/src/persistence/Migrations/058_PullRequestAutoSnoozes.ts new file mode 100644 index 000000000000..c96e451cf66d --- /dev/null +++ b/apps/server/src/persistence/Migrations/058_PullRequestAutoSnoozes.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS pull_request_auto_snoozes ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + watch_id TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + host TEXT, + url TEXT, + state TEXT NOT NULL, + observation_json TEXT NOT NULL, + snoozed_at TEXT, + next_check_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/pullRequest/PullRequestAutoSnooze.test.ts b/apps/server/src/pullRequest/PullRequestAutoSnooze.test.ts new file mode 100644 index 000000000000..08efa4aaa676 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestAutoSnooze.test.ts @@ -0,0 +1,321 @@ +import { assert, it } from "@effect/vitest"; +import { + type GreenPullRequestSnooze, + INDEFINITE_SNOOZE_UNTIL, + type OrchestrationV2Command, + type OrchestrationV2ThreadShell, + ProjectId, + PullRequestWatchId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as TestClock from "effect/testing/TestClock"; + +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { layer as autoSnoozeLayer, PullRequestAutoSnoozeService } from "./PullRequestAutoSnooze.ts"; +import { + PullRequestWatchObserver, + type PullRequestWatchObservation, +} from "./PullRequestWatchObservation.ts"; + +const projectId = ProjectId.make("project:test"); +const threadId = ThreadId.make("thread:test"); +const watchId = PullRequestWatchId.make("watch:test"); +const START = "2026-01-01T00:00:00.000Z"; + +const green: PullRequestWatchObservation = { + headSha: "abc123", + state: "open", + checks: [{ id: "ci", name: "ci", state: "passed", url: "https://ci/1" }], + feedback: [], + feedbackComplete: true, +}; + +type Shell = Pick< + OrchestrationV2ThreadShell, + | "archivedAt" + | "pinnedAt" + | "pendingRuntimeRequest" + | "snoozedUntil" + | "snoozedAt" + | "latestRunCompletedAt" + | "activityRunStatus" + | "status" +>; + +const utc = (iso: string) => DateTime.makeUnsafe(iso); + +const makeHarness = (preference: GreenPullRequestSnooze | null) => + Effect.gen(function* () { + const shell = yield* Ref.make({ + archivedAt: null, + pinnedAt: null, + pendingRuntimeRequest: null, + snoozedUntil: null, + snoozedAt: null, + latestRunCompletedAt: null, + activityRunStatus: "running", + status: "running", + }); + const commands = yield* Ref.make>([]); + const reads = yield* Ref.make>([]); + const readCount = yield* Ref.make(0); + const threads = Layer.mock(ThreadManagementService)({ + getThreadShell: () => + Ref.get(shell).pipe(Effect.map((value) => value as unknown as OrchestrationV2ThreadShell)), + dispatch: (command) => + Effect.gen(function* () { + yield* Ref.update(commands, (all) => [...all, command]); + const now = yield* DateTime.now; + if (command.type === "thread.snooze") { + yield* Ref.update(shell, (value) => ({ + ...value, + snoozedUntil: utc(command.snoozedUntil), + snoozedAt: now, + })); + } + if (command.type === "thread.unsnooze") { + yield* Ref.update(shell, (value) => ({ + ...value, + snoozedUntil: null, + snoozedAt: null, + })); + } + return { sequence: 1, storedEvents: [] }; + }), + }); + const observer = Layer.mock(PullRequestWatchObserver)({ + read: () => + Effect.gen(function* () { + yield* Ref.update(readCount, (n) => n + 1); + const [next, ...rest] = yield* Ref.get(reads); + if (next === undefined) return yield* Effect.die(new Error("no scripted read")); + if (rest.length > 0) yield* Ref.set(reads, rest); + return next; + }), + }); + const service = yield* PullRequestAutoSnoozeService.pipe( + Effect.provide( + autoSnoozeLayer.pipe( + Layer.provide( + Layer.mergeAll( + threads, + observer, + ServerSettings.layerTest({ snoozeGreenPullRequests: preference }), + ), + ), + ), + ), + ); + const armGreen = service.arm({ + projectId, + threadId, + watchId, + repository: "owner/repo", + number: 12, + host: "github.com", + url: "https://github.com/owner/repo/pull/12", + observation: green, + deliveredAt: START, + }); + const finishReply = (status: Shell["status"] = "idle") => + Effect.gen(function* () { + const now = yield* DateTime.now; + yield* Ref.update(shell, (value) => ({ + ...value, + activityRunStatus: null, + status, + latestRunCompletedAt: now, + })); + }); + const snoozeCommands = Ref.get(commands).pipe( + Effect.map((all) => all.filter((command) => command.type === "thread.snooze")), + ); + const wakeCommands = Ref.get(commands).pipe( + Effect.map((all) => all.filter((command) => command.type === "thread.unsnooze")), + ); + const rowCount = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ n: number }>`SELECT COUNT(*) AS n FROM pull_request_auto_snoozes`; + return rows[0]?.n ?? 0; + }); + return { + service, + shell, + reads, + readCount, + armGreen, + finishReply, + snoozeCommands, + wakeCommands, + rowCount, + }; + }); + +const autoSnoozeTest = (name: string, body: () => Effect.Effect) => + it.effect(name, () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(START)); + yield* body(); + }).pipe(Effect.provide(SqlitePersistenceMemory)), + ); + +const snoozed = (preference: GreenPullRequestSnooze = "indefinitely") => + Effect.gen(function* () { + const harness = yield* makeHarness(preference); + yield* harness.armGreen; + yield* TestClock.adjust("30 seconds"); + yield* harness.finishReply(); + yield* harness.service.sweep(); + assert.equal((yield* harness.snoozeCommands).length, 1); + return harness; + }); + +autoSnoozeTest("does nothing while the setting is off", () => + Effect.gen(function* () { + const harness = yield* makeHarness(null); + yield* harness.armGreen; + yield* harness.finishReply(); + yield* harness.service.sweep(); + assert.equal(yield* harness.rowCount, 0); + assert.equal((yield* harness.snoozeCommands).length, 0); + }), +); + +autoSnoozeTest("waits for the agent's reply, then snoozes indefinitely", () => + Effect.gen(function* () { + const harness = yield* makeHarness("indefinitely"); + yield* harness.armGreen; + yield* harness.service.sweep(); + assert.equal((yield* harness.snoozeCommands).length, 0); + yield* TestClock.adjust("30 seconds"); + yield* harness.finishReply(); + yield* harness.service.sweep(); + const [command] = yield* harness.snoozeCommands; + assert.equal( + command?.type === "thread.snooze" ? command.snoozedUntil : null, + INDEFINITE_SNOOZE_UNTIL, + ); + }), +); + +autoSnoozeTest("snoozes for the configured number of hours", () => + Effect.gen(function* () { + const harness = yield* snoozed(3); + const [command] = yield* harness.snoozeCommands; + assert.equal( + command?.type === "thread.snooze" ? command.snoozedUntil : null, + "2026-01-01T03:00:30.000Z", + ); + }), +); + +autoSnoozeTest("a failed reply leaves the thread awake", () => + Effect.gen(function* () { + const harness = yield* makeHarness("indefinitely"); + yield* harness.armGreen; + yield* TestClock.adjust("30 seconds"); + yield* harness.finishReply("failed"); + yield* harness.service.sweep(); + assert.equal((yield* harness.snoozeCommands).length, 0); + assert.equal(yield* harness.rowCount, 0); + }), +); + +autoSnoozeTest("a pinned thread is never auto-snoozed", () => + Effect.gen(function* () { + const harness = yield* makeHarness("indefinitely"); + yield* harness.armGreen; + yield* Ref.update(harness.shell, (value) => ({ ...value, pinnedAt: utc(START) })); + yield* harness.finishReply(); + yield* harness.service.sweep(); + assert.equal((yield* harness.snoozeCommands).length, 0); + assert.equal(yield* harness.rowCount, 0); + }), +); + +autoSnoozeTest("checks the pull request on the slow cadence and wakes on a new failure", () => + Effect.gen(function* () { + const harness = yield* snoozed(); + yield* Ref.set(harness.reads, [ + green, + { ...green, checks: [{ id: "ci", name: "ci", state: "failed", url: "https://ci/2" }] }, + ]); + yield* TestClock.adjust("14 minutes"); + yield* harness.service.sweep(); + assert.equal(yield* Ref.get(harness.readCount), 0); + yield* TestClock.adjust("1 minute"); + yield* harness.service.sweep(); + assert.equal(yield* Ref.get(harness.readCount), 1); + assert.equal((yield* harness.wakeCommands).length, 0); + yield* TestClock.adjust("15 minutes"); + yield* harness.service.sweep(); + const [wake] = yield* harness.wakeCommands; + assert.deepStrictEqual( + wake?.type === "thread.unsnooze" ? [wake.reason, wake.wakeReasons] : null, + ["pull-request", ["check_failed"]], + ); + assert.equal(yield* harness.rowCount, 0); + }), +); + +autoSnoozeTest("new review feedback wakes the thread", () => + Effect.gen(function* () { + const harness = yield* snoozed(); + yield* Ref.set(harness.reads, [ + { + ...green, + feedback: [{ id: "r1", updatedAt: START, body: "please rename", url: null }], + }, + ]); + yield* TestClock.adjust("15 minutes"); + yield* harness.service.sweep(); + const [wake] = yield* harness.wakeCommands; + assert.deepStrictEqual(wake?.type === "thread.unsnooze" ? wake.wakeReasons : null, [ + "review_feedback", + ]); + }), +); + +autoSnoozeTest("a merge ends the watch without waking the thread", () => + Effect.gen(function* () { + const harness = yield* snoozed(); + yield* Ref.set(harness.reads, [{ ...green, state: "merged" }]); + yield* TestClock.adjust("15 minutes"); + yield* harness.service.sweep(); + assert.equal((yield* harness.wakeCommands).length, 0); + assert.equal(yield* harness.rowCount, 0); + assert.isNotNull((yield* Ref.get(harness.shell)).snoozedUntil); + }), +); + +autoSnoozeTest("a thread woken some other way stops being watched without a read", () => + Effect.gen(function* () { + const harness = yield* snoozed(); + yield* Ref.update(harness.shell, (value) => ({ + ...value, + snoozedUntil: null, + snoozedAt: null, + })); + yield* TestClock.adjust("15 minutes"); + yield* harness.service.sweep(); + assert.equal(yield* Ref.get(harness.readCount), 0); + assert.equal(yield* harness.rowCount, 0); + }), +); + +autoSnoozeTest("a timed snooze that expires stops being watched", () => + Effect.gen(function* () { + const harness = yield* snoozed(1); + yield* TestClock.adjust("61 minutes"); + yield* harness.service.sweep(); + assert.equal(yield* Ref.get(harness.readCount), 0); + assert.equal(yield* harness.rowCount, 0); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestAutoSnooze.ts b/apps/server/src/pullRequest/PullRequestAutoSnooze.ts new file mode 100644 index 000000000000..10c4fab63c64 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestAutoSnooze.ts @@ -0,0 +1,306 @@ +import { + CommandId, + INDEFINITE_SNOOZE_UNTIL, + type OrchestrationV2ThreadShell, + ProjectId, + PullRequestWatchError, + PullRequestWatchId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + matchesWatchEvent, + PullRequestWatchObservation, + PullRequestWatchObserver, +} from "./PullRequestWatchObservation.ts"; + +export const WAKE_CHECK_INTERVAL_MS = 15 * 60 * 1000; +export const PENDING_SNOOZE_TIMEOUT_MS = 60 * 60 * 1000; + +export interface PullRequestAutoSnoozeArmInput { + readonly projectId: ProjectId; + readonly threadId: ThreadId; + readonly watchId: PullRequestWatchId; + readonly repository: string; + readonly number: number; + readonly host: string; + readonly url: string | null; + readonly observation: PullRequestWatchObservation; + readonly deliveredAt: string; +} + +export class PullRequestAutoSnoozeService extends Context.Service< + PullRequestAutoSnoozeService, + { + readonly arm: ( + input: PullRequestAutoSnoozeArmInput, + ) => Effect.Effect; + readonly sweep: () => Effect.Effect; + } +>()("t3/pullRequest/PullRequestAutoSnooze/PullRequestAutoSnoozeService") {} + +interface AutoSnoozeRow { + readonly thread_id: string; + readonly project_id: string; + readonly watch_id: string; + readonly repository: string; + readonly number: number; + readonly host: string | null; + readonly url: string | null; + readonly state: "pending" | "snoozed"; + readonly observation_json: string; + readonly snoozed_at: string | null; + readonly next_check_at: string | null; + readonly created_at: string; +} + +const ObservationJson = Schema.fromJsonString(PullRequestWatchObservation); +const decodeObservation = Schema.decodeUnknownEffect(ObservationJson); +const encodeObservation = Schema.encodeSync(ObservationJson); + +const iso = (value: DateTime.DateTime): string => DateTime.formatIso(DateTime.toUtc(value)); +const millis = (value: DateTime.Utc | null | undefined): number | null => + value == null ? null : DateTime.toEpochMillis(value); +const autoSnoozeError = (message: string) => new PullRequestWatchError({ message }); + +type PendingDecision = "wait" | "snooze" | "drop"; + +export function decidePendingSnooze( + thread: OrchestrationV2ThreadShell | null, + armedAtMs: number, + nowMs: number, +): PendingDecision { + if (thread === null || thread.archivedAt !== null || thread.pinnedAt != null) return "drop"; + if (thread.pendingRuntimeRequest !== null) return "drop"; + const snoozedUntilMs = millis(thread.snoozedUntil); + if (snoozedUntilMs !== null && snoozedUntilMs > nowMs) return "drop"; + const completedAtMs = millis(thread.latestRunCompletedAt); + const replied = completedAtMs !== null && completedAtMs >= armedAtMs; + if (thread.activityRunStatus != null || !replied) { + return nowMs - armedAtMs > PENDING_SNOOZE_TIMEOUT_MS ? "drop" : "wait"; + } + return thread.status === "idle" || thread.status === "completed" ? "snooze" : "drop"; +} + +export function stillAutoSnoozed( + thread: OrchestrationV2ThreadShell | null, + snoozedAt: string | null, + nowMs: number, +): boolean { + if (thread === null || thread.archivedAt !== null || snoozedAt === null) return false; + const snoozedAtMs = millis(thread.snoozedAt); + const snoozedUntilMs = millis(thread.snoozedUntil); + if (snoozedAtMs === null || snoozedUntilMs === null) return false; + if (snoozedAtMs !== Date.parse(snoozedAt) || snoozedUntilMs <= nowMs) return false; + if (thread.pendingRuntimeRequest !== null) return false; + const completedAtMs = millis(thread.latestRunCompletedAt); + return completedAtMs === null || completedAtMs <= snoozedAtMs; +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const observer = yield* PullRequestWatchObserver; + const threads = yield* ThreadManagementService; + const settings = yield* ServerSettings.ServerSettingsService; + + const deleteRow = (threadId: string) => + sql`DELETE FROM pull_request_auto_snoozes WHERE thread_id = ${threadId}`.pipe( + Effect.mapError((cause) => + autoSnoozeError(`Could not clear pull request auto-snooze: ${cause}`), + ), + ); + + const readShell = (threadId: string) => + threads + .getThreadShell(ThreadId.make(threadId)) + .pipe(Effect.mapError((cause) => autoSnoozeError(`Could not read thread: ${cause.message}`))); + + const arm: PullRequestAutoSnoozeService["Service"]["arm"] = (input) => + Effect.gen(function* () { + const preference = (yield* settings.getSettings.pipe( + Effect.mapError((cause) => autoSnoozeError(`Could not read settings: ${cause}`)), + )).snoozeGreenPullRequests; + if (preference === null) return; + const nowIso = iso(yield* DateTime.now); + yield* sql` + INSERT OR REPLACE INTO pull_request_auto_snoozes ( + thread_id, project_id, watch_id, repository, number, host, url, state, + observation_json, snoozed_at, next_check_at, last_error, created_at, updated_at + ) VALUES ( + ${input.threadId}, ${input.projectId}, ${input.watchId}, ${input.repository}, + ${input.number}, ${input.host}, ${input.url}, 'pending', ${encodeObservation(input.observation)}, + NULL, NULL, NULL, ${input.deliveredAt}, ${nowIso} + ) + `.pipe( + Effect.mapError((cause) => + autoSnoozeError(`Could not arm pull request auto-snooze: ${cause}`), + ), + ); + }); + + const settlePending = (row: AutoSnoozeRow) => + Effect.gen(function* () { + const preference = (yield* settings.getSettings.pipe( + Effect.mapError((cause) => autoSnoozeError(`Could not read settings: ${cause}`)), + )).snoozeGreenPullRequests; + if (preference === null) return yield* deleteRow(row.thread_id); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const thread = yield* readShell(row.thread_id); + const decision = decidePendingSnooze(thread, Date.parse(row.created_at), nowMs); + if (decision === "wait") return; + if (decision === "drop") return yield* deleteRow(row.thread_id); + const snoozedUntil = + preference === "indefinitely" + ? INDEFINITE_SNOOZE_UNTIL + : iso(DateTime.add(now, { hours: preference })); + const dispatched = yield* threads + .dispatch({ + type: "thread.snooze", + commandId: CommandId.make( + `pull-request-auto-snooze:${row.watch_id}:${millis(thread?.latestRunCompletedAt)}`, + ), + threadId: ThreadId.make(row.thread_id), + snoozedUntil, + pullRequest: { repository: row.repository, number: row.number, url: row.url }, + }) + .pipe(Effect.result); + if (!Result.isSuccess(dispatched)) { + return yield* sql` + UPDATE pull_request_auto_snoozes + SET last_error = ${String(dispatched.failure.message).slice(0, 2000)}, updated_at = ${iso(now)} + WHERE thread_id = ${row.thread_id} + `.pipe( + Effect.mapError((cause) => + autoSnoozeError(`Could not record auto-snooze error: ${cause}`), + ), + ); + } + const snoozed = yield* readShell(row.thread_id); + const snoozedAt = snoozed?.snoozedAt == null ? null : iso(snoozed.snoozedAt); + if (snoozedAt === null) return yield* deleteRow(row.thread_id); + yield* sql` + UPDATE pull_request_auto_snoozes + SET state = 'snoozed', snoozed_at = ${snoozedAt}, + next_check_at = ${iso(DateTime.add(now, { milliseconds: WAKE_CHECK_INTERVAL_MS }))}, + last_error = NULL, updated_at = ${iso(now)} + WHERE thread_id = ${row.thread_id} + `.pipe(Effect.mapError((cause) => autoSnoozeError(`Could not record auto-snooze: ${cause}`))); + }); + + const checkSnoozed = (row: AutoSnoozeRow) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const nowIso = iso(now); + const thread = yield* readShell(row.thread_id); + if (!stillAutoSnoozed(thread, row.snoozed_at, DateTime.toEpochMillis(now))) { + return yield* deleteRow(row.thread_id); + } + const nextCheckAt = iso(DateTime.add(now, { milliseconds: WAKE_CHECK_INTERVAL_MS })); + const read = yield* observer + .read({ + projectId: ProjectId.make(row.project_id), + repository: row.repository, + number: row.number, + ...(row.host === null ? {} : { host: row.host }), + refresh: true, + }) + .pipe(Effect.result); + if (!Result.isSuccess(read)) { + if (read.failure.reason === "not-found") return yield* deleteRow(row.thread_id); + return yield* sql` + UPDATE pull_request_auto_snoozes + SET next_check_at = ${nextCheckAt}, last_error = ${read.failure.message.slice(0, 2000)}, + updated_at = ${nowIso} + WHERE thread_id = ${row.thread_id} + `.pipe( + Effect.mapError((cause) => + autoSnoozeError(`Could not record auto-snooze error: ${cause}`), + ), + ); + } + const next = read.success; + if (next.state !== "open") return yield* deleteRow(row.thread_id); + const previous = yield* decodeObservation(row.observation_json).pipe( + Effect.mapError(() => autoSnoozeError("Could not decode auto-snooze cursor.")), + ); + const wakeReasons = (["check_failed", "review_feedback"] as const).filter((event) => + matchesWatchEvent({ previous, next, event }), + ); + if (wakeReasons.length > 0) { + yield* threads + .dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make(`pull-request-auto-wake:${row.watch_id}:${row.snoozed_at}`), + threadId: ThreadId.make(row.thread_id), + reason: "pull-request", + wakeReasons, + }) + .pipe( + Effect.mapError((cause) => + autoSnoozeError(`Could not wake auto-snoozed thread: ${cause.message}`), + ), + ); + return yield* deleteRow(row.thread_id); + } + const cursor = next.feedbackComplete + ? next + : { ...next, feedback: previous.feedback, feedbackComplete: previous.feedbackComplete }; + yield* sql` + UPDATE pull_request_auto_snoozes + SET observation_json = ${encodeObservation(cursor)}, next_check_at = ${nextCheckAt}, + last_error = NULL, updated_at = ${nowIso} + WHERE thread_id = ${row.thread_id} + `.pipe( + Effect.mapError((cause) => autoSnoozeError(`Could not advance auto-snooze: ${cause}`)), + ); + }); + + const sweep: PullRequestAutoSnoozeService["Service"]["sweep"] = () => + Effect.gen(function* () { + const nowIso = iso(yield* DateTime.now); + const rows = yield* sql` + SELECT * FROM pull_request_auto_snoozes + WHERE state = 'pending' OR next_check_at <= ${nowIso} + ORDER BY updated_at ASC + `.pipe(Effect.mapError((cause) => autoSnoozeError(`Could not list auto-snoozes: ${cause}`))); + yield* Effect.forEach( + rows, + (row) => + (row.state === "pending" ? settlePending(row) : checkSnoozed(row)).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("pull request auto-snooze skipped", { + threadId: row.thread_id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }); + + return PullRequestAutoSnoozeService.of({ arm, sweep }); +}); + +export const layer = Layer.effect(PullRequestAutoSnoozeService, make); + +export const workerLive = Layer.effectDiscard( + Effect.gen(function* () { + const service = yield* PullRequestAutoSnoozeService; + const scheduler = yield* Scheduler.Scheduler; + yield* scheduler.register("pull-request-auto-snooze", service.sweep()); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestWatchService.test.ts b/apps/server/src/pullRequest/PullRequestWatchService.test.ts index 9191ea1183ec..ab4474170fbc 100644 --- a/apps/server/src/pullRequest/PullRequestWatchService.test.ts +++ b/apps/server/src/pullRequest/PullRequestWatchService.test.ts @@ -38,6 +38,10 @@ import { layer as pullRequestWatchServiceLayer, PullRequestWatchService, } from "./PullRequestWatchService.ts"; +import { + type PullRequestAutoSnoozeArmInput, + PullRequestAutoSnoozeService, +} from "./PullRequestAutoSnooze.ts"; const projectId = ProjectId.make("project:test"); const threadId = ThreadId.make("thread:test"); @@ -105,6 +109,7 @@ interface Harness { readonly providerRosters: Ref.Ref< Record> >; + readonly armed: Ref.Ref>; readonly service: PullRequestWatchService["Service"]; } @@ -130,6 +135,10 @@ const makeHarness = ( const providerRosters = yield* Ref.make< Record> >({}); + const armed = yield* Ref.make>([]); + const autoSnoozeMock = Layer.mock(PullRequestAutoSnoozeService)({ + arm: (input) => Ref.update(armed, (all) => [...all, input]), + }); const observerMock = Layer.mock(PullRequestWatchObserver)({ read: () => Effect.gen(function* () { @@ -292,6 +301,7 @@ const makeHarness = ( pullRequestWatchServiceLayer, Layer.mergeAll( observerMock, + autoSnoozeMock, threadMock, eventSinkMock, IdAllocator.layer, @@ -310,6 +320,7 @@ const makeHarness = ( rosterWrites, activeProviderThreadId, providerRosters, + armed, service, }; }); @@ -592,6 +603,36 @@ watchTest("reports checks_finished with verdict counts, never empty success", () }), ); +watchTest("only an all-green delivery arms the auto-snooze", () => + Effect.gen(function* () { + const { service, armed } = yield* makeHarness([ + obs({ checks: [check("a", "passed"), check("b", "skipped")] }), + ]); + yield* register(service, { events: ["checks_finished"], clientRequestId: "green" }); + assert.equal((yield* Ref.get(armed)).length, 0); + yield* tick(service, "16 seconds"); + const [arm] = yield* Ref.get(armed); + assert.equal(arm?.threadId, threadId); + assert.equal(arm?.host, "github.com"); + assert.deepStrictEqual( + arm?.observation.checks.map((c) => c.state), + ["passed", "skipped"], + ); + }), +); + +watchTest("a finished rollup with a failure never arms the auto-snooze", () => + Effect.gen(function* () { + const { service, sent, armed } = yield* makeHarness([ + obs({ checks: [check("a", "passed"), check("b", "cancelled")] }), + ]); + yield* register(service, { events: ["checks_finished"], clientRequestId: "mixed" }); + yield* tick(service, "16 seconds"); + assert.equal((yield* Ref.get(sent)).length, 1); + assert.equal((yield* Ref.get(armed)).length, 0); + }), +); + watchTest("an empty check list never matches checks_finished", () => Effect.gen(function* () { const { service, sent } = yield* makeHarness([obs()]); diff --git a/apps/server/src/pullRequest/PullRequestWatchService.ts b/apps/server/src/pullRequest/PullRequestWatchService.ts index 3103454150bb..48e24026083b 100644 --- a/apps/server/src/pullRequest/PullRequestWatchService.ts +++ b/apps/server/src/pullRequest/PullRequestWatchService.ts @@ -35,6 +35,7 @@ import { import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; +import { PullRequestAutoSnoozeService } from "./PullRequestAutoSnooze.ts"; import { matchesWatchEvent, PullRequestWatchObservation, @@ -90,6 +91,7 @@ const MatchedPayload = Schema.Struct({ closeReason: Schema.optional(Schema.Literals(["merged", "closed", "not-found"])), /** Retained across delivery retries, whose errors temporarily occupy last_error. */ observationError: Schema.optional(Schema.String), + green: Schema.optional(Schema.Boolean), }); type MatchedPayload = typeof MatchedPayload.Type; @@ -271,6 +273,7 @@ export const layer = Layer.effect( const sql = yield* SqlClient.SqlClient; const crypto = yield* Crypto.Crypto; const observer = yield* PullRequestWatchObserver; + const autoSnooze = yield* PullRequestAutoSnoozeService; const threadManagement = yield* ThreadManagementService; const eventSink = yield* EventSink.EventSinkV2; const ids = yield* IdAllocator.IdAllocatorV2; @@ -585,6 +588,32 @@ export const layer = Layer.effect( ThreadId.make(row.thread_id), ProjectId.make(row.project_id), ); + const greenObservation = + frozenPayload.green === true && frozenPayload.closeReason === undefined + ? yield* decodeObservationJson(current.last_observation_json) + : null; + if (greenObservation !== null) { + yield* autoSnooze + .arm({ + projectId: ProjectId.make(row.project_id), + threadId: ThreadId.make(row.thread_id), + watchId, + repository: row.repository, + number: row.number, + host: canonicalHost(row.host), + url: displayUrl(row), + observation: greenObservation, + deliveredAt: nowIso, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("pull request auto-snooze could not arm", { + watchId, + error: error.message, + }), + ), + ); + } }), ); @@ -643,12 +672,17 @@ export const layer = Layer.effect( ); details.push(detailFeedback(fresh)); } + const green = + hits.length === 1 && + hits[0] === "checks_finished" && + next.checks.every((check) => check.state === "passed" || check.state === "skipped"); return { summary: summaries.join("; "), detail: details.join("\n"), outcome, headSha: next.headSha, events: hits, + ...(green ? { green } : {}), }; }; @@ -691,6 +725,7 @@ export const layer = Layer.effect( // old row has a null head, retain the later evidence instead. headSha: first.headSha ?? later.headSha, events: [...new Set([...first.events, ...later.events])], + ...(first.green === true && later.green === true ? { green: true } : {}), ...(later.closeReason === undefined && first.closeReason === undefined ? {} : { closeReason: later.closeReason ?? first.closeReason }), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 70f5904d1a37..9c5b5fbae2db 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,7 +78,12 @@ import { } from "@t3tools/client-runtime/errors"; import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; -import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSnoozed, + pullRequestWakeLabel, + threadWokeAt, + watchedPullRequestLabel, +} from "@t3tools/client-runtime/state/thread-settled"; import { useThreadActions } from "../hooks/useThreadActions"; import { deriveThreadActivityRun, @@ -263,6 +268,8 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + EyeClosedIcon, + EyeIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -6804,27 +6811,58 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadWokeVisible) { return null; } + const pullRequestWake = + activeThreadShell === null ? null : pullRequestWakeLabel(activeThreadShell); + const pullRequestUrl = activeThreadShell?.pullRequestSnooze?.url ?? null; return { id: `thread-woke:${activeThread?.id ?? "unknown"}`, variant: "info", - icon: , - title: "Thread woke from snooze", + icon: pullRequestWake === null ? : , + title: pullRequestWake ?? "Thread woke from snooze", description: "Send a message to continue", + ...(pullRequestWake === null || pullRequestUrl === null + ? {} + : { + actions: ( + + ), + }), dismissLabel: "Dismiss Woke notification", onDismiss: acknowledgeActiveThreadWoke, }; - }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); + }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadShell, activeThreadWokeVisible]); const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; } const isSnoozed = activeThreadSnoozed; + const watchedPullRequest = + isSnoozed && activeThreadShell !== null ? watchedPullRequestLabel(activeThreadShell) : null; return { id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, variant: "info", - icon: isSnoozed ? : , - title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, - description: `Send a message to ${isSnoozed ? "wake" : "unsettle"}`, + icon: + watchedPullRequest !== null ? ( + + ) : isSnoozed ? ( + + ) : ( + + ), + title: + watchedPullRequest !== null + ? `Snoozed · ${watchedPullRequest.toLowerCase()}` + : `This thread is ${isSnoozed ? "snoozed" : "settled"}`, + description: + watchedPullRequest !== null + ? "Wakes on a failed check or new review feedback" + : `Send a message to ${isSnoozed ? "wake" : "unsettle"}`, actions: ( } /> - Dismiss Woke notification + {wokeTooltip} ) : ( @@ -1760,7 +1787,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onClick={handleUnsnoozeClick} className={cn( "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", - isWoke && "group-hover/sidebar-row:static", + (isWoke || watchedPullRequest !== null) && "group-hover/sidebar-row:static", )} > @@ -1891,12 +1918,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { topStatus.className, )} > - + {topStatus.label} } /> - Dismiss Woke notification + {wokeTooltip} ) : ( { ["persistComposerContextStrip", "Composer context"], ["autoResumeLimitedThreads", "Auto-resume limited threads"], ["snoozeLimitedThreads", "Snooze limited threads"], + ["snoozeGreenPullRequests", "Snooze green pull requests"], ] as const)("restores %s when it is the only changed setting", async (key, label) => { - state.settings = { ...DEFAULT_UNIFIED_SETTINGS, [key]: true }; + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + [key]: key === "snoozeGreenPullRequests" ? "indefinitely" : true, + }; hooks.beginRender(); const restore = useSettingsRestore(); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0ca265a620a4..972f6c33db35 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -203,6 +203,16 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { @@ -557,6 +567,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), + ...(settings.snoozeGreenPullRequests !== DEFAULT_UNIFIED_SETTINGS.snoozeGreenPullRequests + ? ["Snooze green pull requests"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip @@ -676,6 +689,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, + settings.snoozeGreenPullRequests, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -778,6 +792,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, + snoozeGreenPullRequests: DEFAULT_UNIFIED_SETTINGS.snoozeGreenPullRequests, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2115,6 +2130,7 @@ export function GeneralSettingsPanel() { const hasServerTargets = connectedEnvironments.length > 0; const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); + const mixedSnoozeGreenPullRequests = useScopedSettingsMixed(["snoozeGreenPullRequests"]); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -2124,6 +2140,11 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true, ); + const supportsPullRequestWatchAutoSnooze = + connectedEnvironments.length > 0 && + connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.pullRequestWatchAutoSnooze === true, + ); const supportsRestartContinuation = connectedEnvironments.length > 0 && connectedEnvironments.every( @@ -2254,6 +2275,72 @@ export function GeneralSettingsPanel() { /> } /> + {supportsPullRequestWatchAutoSnooze ? ( + + updateSettings({ + snoozeGreenPullRequests: DEFAULT_UNIFIED_SETTINGS.snoozeGreenPullRequests, + }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} {supportsAutoSettlement ? ( <> { "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", + "snoozeGreenPullRequests", "snoozeLimitedThreads", "sourceControlWritingStyle", "textGenerationModelSelection", diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 1d6fb5e14ca7..ac12b24af0e7 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -27,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "autoResumeLimitedThreads", "snoozeLimitedThreads", + "snoozeGreenPullRequests", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 5750ac1f911e..7d9ba269dcfa 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -413,6 +413,7 @@ export function createThreadEnvironmentAtoms( ...thread, pendingRuntimeRequest: null, snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), + pullRequestSnooze: null, snoozedAt: thread.snoozedUntil != null && DateTime.formatIso(thread.snoozedUntil) === input.snoozedUntil @@ -424,6 +425,7 @@ export function createThreadEnvironmentAtoms( ...thread, snoozedUntil: null, snoozedAt: null, + pullRequestSnooze: null, })), pin: optimistic.wrap(commands.pin, (thread, input, now) => ({ ...thread, @@ -438,6 +440,7 @@ export function createThreadEnvironmentAtoms( : {}), snoozedUntil: null, snoozedAt: null, + pullRequestSnooze: null, })), unpin: optimistic.wrap(commands.unpin, (thread) => ({ ...thread, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 2523a5b466b3..aea4b975234a 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,5 +1,9 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. -import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { + INDEFINITE_SNOOZE_UNTIL, + type OrchestrationThreadShell, + type OrchestrationV2PullRequestSnooze, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; interface SettlementRunLike { @@ -97,6 +101,7 @@ export function hasQueuedTurnStart( export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; + readonly pullRequestSnooze?: OrchestrationV2PullRequestSnooze | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; } @@ -195,7 +200,7 @@ export function threadWokeAt( shell: ThreadSnoozeShell, options: { readonly now: string }, ): string | null { - if (shell.snoozedUntil == null) return null; + if (shell.snoozedUntil == null) return shell.pullRequestSnooze?.wokeAt ?? null; const wakeAtMs = Date.parse(shell.snoozedUntil); if (Number.isNaN(wakeAtMs)) return null; // An early hand-raise wake stays authoritative even after the scheduled @@ -312,14 +317,16 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { } /** - * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes - * round up so a snooze never reads "0m" while still hidden. Shared by web + * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d", or "∞" for an + * open-ended snooze. Minutes round up so a snooze never reads "0m" while + * still hidden. Shared by web * and mobile so the same wake time never reads differently per client. */ export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string { const wakeMs = Date.parse(snoozedUntil); const nowMs = Date.parse(options.now); if (Number.isNaN(wakeMs) || Number.isNaN(nowMs)) return "now"; + if (wakeMs >= Date.parse(INDEFINITE_SNOOZE_UNTIL)) return "∞"; const remainingMs = wakeMs - nowMs; if (remainingMs <= 0) return "now"; if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; @@ -361,3 +368,29 @@ export function localSnoozeDate(date: Date): string { export function localSnoozeTime(date: Date): string { return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; } + +/** + * Why a pull request woke this thread, for the Woke tooltip: "New review + * comment on #13", "Check failed on #13". Null when something else woke it. + */ +export function pullRequestWakeLabel(shell: ThreadSnoozeShell): string | null { + const snooze = shell.pullRequestSnooze; + if (snooze == null || snooze.wokeAt === null || shell.snoozedUntil != null) return null; + const reasons = snooze.wakeReasons; + const what = + reasons.includes("check_failed") && reasons.includes("review_feedback") + ? "Check failed and new review feedback" + : reasons.includes("check_failed") + ? "Check failed" + : reasons.includes("review_feedback") + ? "New review feedback" + : "Pull request changed"; + return `${what} on #${snooze.number}`; +} + +/** The pull request a snoozed thread is still watching, for its snoozed-row icon. */ +export function watchedPullRequestLabel(shell: ThreadSnoozeShell): string | null { + const snooze = shell.pullRequestSnooze; + if (snooze == null || snooze.wokeAt !== null || shell.snoozedUntil == null) return null; + return `Watching #${snooze.number}`; +} diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 8a62103950bf..78819111e810 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. -import { ThreadId } from "@t3tools/contracts"; +import { INDEFINITE_SNOOZE_UNTIL, ThreadId } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -7,11 +7,13 @@ import { canSnooze, effectiveSnoozed, hasQueuedTurnStart, + pullRequestWakeLabel, resolveSnoozePresets, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, type ThreadSnoozeShell, + watchedPullRequestLabel, } from "./threadSettled.ts"; import type { OrchestrationThreadShell } from "@t3tools/contracts"; @@ -303,6 +305,39 @@ describe("threadWokeAt", () => { }); }); +describe("pull request snoozes", () => { + const watching = { + repository: "owner/repo", + number: 13, + url: null, + wokeAt: null, + wakeReasons: [], + }; + const woke = { ...watching, wokeAt: "2026-04-10T11:30:00.000Z" }; + + it("labels a snoozed thread that is still watching its pull request", () => { + const shell = { ...makeShell({ snoozedUntil: FUTURE_WAKE }), pullRequestSnooze: watching }; + expect(watchedPullRequestLabel(shell)).toBe("Watching #13"); + expect(pullRequestWakeLabel(shell)).toBe(null); + }); + + it("reports a pull request wake and why it happened", () => { + const shell = { + ...makeShell({}), + pullRequestSnooze: { ...woke, wakeReasons: ["review_feedback" as const] }, + }; + expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T11:30:00.000Z"); + expect(pullRequestWakeLabel(shell)).toBe("New review feedback on #13"); + expect(watchedPullRequestLabel(shell)).toBe(null); + expect( + pullRequestWakeLabel({ + ...shell, + pullRequestSnooze: { ...woke, wakeReasons: ["check_failed", "review_feedback"] }, + }), + ).toBe("Check failed and new review feedback on #13"); + }); +}); + describe("snoozeWakeLabel", () => { const now = "2026-06-02T00:00:00.000Z"; @@ -318,6 +353,10 @@ describe("snoozeWakeLabel", () => { expect(snoozeWakeLabel("not-a-date", { now })).toBe("now"); expect(snoozeWakeLabel("2026-06-02T09:00:00.000Z", { now: "bad" })).toBe("now"); }); + + it("shows no countdown for an open-ended snooze", () => { + expect(snoozeWakeLabel(INDEFINITE_SNOOZE_UNTIL, { now })).toBe("∞"); + }); }); describe("resolveSnoozePresets", () => { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index d7b62fbf5f84..d8d9a8cf96e3 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -158,6 +158,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ an unknown method. */ pullRequestWatches: Schema.optionalKey(Schema.Boolean), + pullRequestWatchAutoSnooze: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 82dc39e52dcd..c9f00a91c6b0 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -336,6 +336,21 @@ export const OrchestrationV2LimitRecovery = Schema.Struct({ }); export type OrchestrationV2LimitRecovery = typeof OrchestrationV2LimitRecovery.Type; +export const OrchestrationV2PullRequestWakeReason = Schema.Literals([ + "check_failed", + "review_feedback", +]); +export type OrchestrationV2PullRequestWakeReason = typeof OrchestrationV2PullRequestWakeReason.Type; + +export const OrchestrationV2PullRequestSnooze = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: Schema.NullOr(Schema.String), + wokeAt: Schema.NullOr(IsoDateTime), + wakeReasons: Schema.Array(OrchestrationV2PullRequestWakeReason), +}); +export type OrchestrationV2PullRequestSnooze = typeof OrchestrationV2PullRequestSnooze.Type; + /** A choice update preserves omitted options for this same run and reset. */ export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ runId: RunId, @@ -396,6 +411,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), + pullRequestSnooze: Schema.optional(Schema.NullOr(OrchestrationV2PullRequestSnooze)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), // Fractional-index slot in the user-arranged pinned order. Optional so // payloads from pre-reorder servers still decode. @@ -1548,6 +1564,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), + pullRequestSnooze: Schema.optional(Schema.NullOr(OrchestrationV2PullRequestSnooze)), /** Omitted by servers that predate thread pinning. */ pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), /** Slot in the user-arranged pinned order; omitted by pre-reorder servers. */ @@ -2296,12 +2313,20 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, snoozedUntil: IsoDateTime, + pullRequest: Schema.optional( + OrchestrationV2PullRequestSnooze.mapFields(({ repository, number, url }) => ({ + repository, + number, + url, + })), + ), }), Schema.Struct({ type: Schema.Literal("thread.unsnooze"), commandId: CommandId, threadId: ThreadId, - reason: Schema.Literal("user"), + reason: Schema.Literals(["user", "pull-request"]), + wakeReasons: Schema.optional(Schema.Array(OrchestrationV2PullRequestWakeReason)), }), Schema.Struct({ type: Schema.Literal("thread.pin"), diff --git a/packages/contracts/src/pullRequestWatch.ts b/packages/contracts/src/pullRequestWatch.ts index 52e9fa61f041..26afd4f213cf 100644 --- a/packages/contracts/src/pullRequestWatch.ts +++ b/packages/contracts/src/pullRequestWatch.ts @@ -66,3 +66,5 @@ export class PullRequestWatchError extends Schema.TaggedError