Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { useRef, useState } from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import {
DEFAULT_SERVER_SETTINGS,
DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS,
type ServerSettingsPatch,
} from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { AppText as Text } from "../../components/AppText";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
Expand All @@ -20,7 +24,7 @@ import {
AndroidSettingsEnvironmentFilter,
SettingsEnvironmentFilterHeader,
} from "./components/SettingsEnvironmentFilterHeader";
import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import {
planMobileScopedSettingsClear,
Expand Down Expand Up @@ -84,7 +88,7 @@ function AutoSettleSettingsRows() {
return null;
}

const writeToAll = (patch: Partial<AutoSettleSettings>) => {
const writeToAll = (patch: ServerSettingsPatch) => {
if (writeInFlight.current) return;
const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch);
if (writes.length === 0) return;
Expand Down Expand Up @@ -116,6 +120,9 @@ function AutoSettleSettingsRows() {
})),
);

const supportsAutoArchive = syncTargets.every(
(target) => target.environment.serverConfig.environment.capabilities.threadAutoArchive === true,
);
const supportsProjectOverrides = syncTargets.every(
(target) =>
target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
Expand All @@ -126,13 +133,15 @@ function AutoSettleSettingsRows() {
syncTargets.some(
(target) =>
target.sources.sidebarAutoSettleOnMerge === "project" ||
target.sources.sidebarAutoSettleAfterDays === "project",
target.sources.sidebarAutoSettleAfterDays === "project" ||
target.sources.sidebarAutoArchiveAfterDays === "project",
);
const clearProjectOverrides = () => {
if (writeInFlight.current) return;
const writes = planMobileScopedSettingsClear(syncTargets, [
"sidebarAutoSettleOnMerge",
"sidebarAutoSettleAfterDays",
"sidebarAutoArchiveAfterDays",
]);
if (writes.length === 0) return;
writeInFlight.current = true;
Expand All @@ -150,6 +159,7 @@ function AutoSettleSettingsRows() {
};

const afterDays = referenceSettings.sidebarAutoSettleAfterDays;
const archiveAfterDays = referenceSettings.sidebarAutoArchiveAfterDays;

return (
<View className="gap-6">
Expand Down Expand Up @@ -186,11 +196,39 @@ function AutoSettleSettingsRows() {
<AutoSettleDaysField
value={afterDays}
disabled={disabled}
action="auto-settle"
onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })}
/>
</View>
) : null}
</SettingsSection>
{supportsAutoArchive ? (
<SettingsSection title="Auto-archive">
<SettingsSwitchRow
icon="archivebox"
label="Auto-archive settled threads"
value={archiveAfterDays !== null}
disabled={disabled}
onValueChange={(value) =>
writeToAll({
sidebarAutoArchiveAfterDays: value ? DEFAULT_SIDEBAR_AUTO_ARCHIVE_AFTER_DAYS : null,
})
}
/>
{archiveAfterDays !== null ? (
<View className="flex-row items-center gap-4 px-4 py-4 android:min-h-14 android:py-3">
<View className="w-[22px] android:w-6" />
<Text className="flex-1 text-foreground text-lg android:text-base">Settled days</Text>
<AutoSettleDaysField
value={archiveAfterDays}
disabled={disabled}
action="auto-archive"
onValueChange={(value) => writeToAll({ sidebarAutoArchiveAfterDays: value })}
/>
</View>
) : null}
</SettingsSection>
) : null}
{pendingWrites === 0 && mismatches.length > 0 ? (
<SettingsSection title="Across environments">
<View className="gap-3 p-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,21 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
/>
<MaterialIconButton
icon="minus"
accessibilityLabel="Decrease days before auto-settle"
accessibilityLabel={`Decrease days before ${props.action}`}
disabled={props.disabled || props.value <= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
onPress={() => adjust(-1)}
/>
<AppText
className="min-w-8 text-center text-base"
style={{ fontVariant: ["tabular-nums"] }}
accessibilityLabel={`${props.value} ${props.value === 1 ? "day" : "days"} before auto-settle`}
accessibilityLabel={`${props.value} ${props.value === 1 ? "day" : "days"} before ${props.action}`}
accessibilityLiveRegion="polite"
>
{props.value}
</AppText>
<MaterialIconButton
icon="plus"
accessibilityLabel="Increase days before auto-settle"
accessibilityLabel={`Increase days before ${props.action}`}
disabled={props.disabled || props.value >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
onPress={() => adjust(1)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
modifiers={[
buttonStyle("bordered"),
disabled(props.disabled),
accessibilityLabel(`Days before auto-settle: ${props.value}`),
accessibilityLabel(`Days before ${props.action}: ${props.value}`),
frame({ minWidth: 64, minHeight: 44 }),
foregroundStyle(colors["--color-primary-text"]),
font({ size: appearance.baseFontSize }),
Expand All @@ -60,7 +60,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
]}
>
<Picker
label="Days before auto-settle"
label={`Days before ${props.action}`}
selection={draft}
onSelectionChange={setDraft}
modifiers={[pickerStyle("wheel"), frame({ height: 180 })]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface AutoSettleDaysFieldProps {
readonly value: number;
readonly onValueChange: (value: number) => void;
readonly disabled?: boolean;
/** Names the automation in accessibility labels, such as "auto-settle". */
readonly action: string;
}

export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
Expand Down Expand Up @@ -41,7 +43,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
onChangeText={setDraft}
onBlur={commit}
onSubmitEditing={commit}
accessibilityLabel="Days before auto-settle"
accessibilityLabel={`Days before ${props.action}`}
editable={!props.disabled}
/>
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {
threadPinReorder: true,
threadActiveReorder: true,
threadAutoSettleOptOut: true,
threadAutoArchive: true,
threadTitleRegeneration: true,
threadPullRequests: true,
pullRequestStackActions: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
yield* projectionThreadRepository.upsert({
...existingRow.value,
archivedAt: null,
...(event.payload.settledAt !== undefined
? { settledAt: event.payload.settledAt }
: {}),
updatedAt: event.payload.updatedAt,
});
return;
Expand Down
33 changes: 32 additions & 1 deletion apps/server/src/orchestration/ThreadSettlementPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
type OrchestrationThreadShell,
type ThreadPullRequestLink,
} from "@t3tools/contracts";
import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
import {
type SettlementPullRequest,
isAutoArchiveDue,
resolveAutoSettlementAt,
} from "./ThreadSettlementPolicy.ts";

const NOW = "2026-08-28T12:00:00.000Z";
const makeThread = (
Expand Down Expand Up @@ -311,3 +315,30 @@ describe("linked request settlement", () => {
);
});
});

describe("isAutoArchiveDue", () => {
const settled = { settledOverride: "settled" as const, settledAt: "2026-07-28T12:00:00.000Z" };

it("archives once a thread has been settled for the configured days", () => {
expect(isAutoArchiveDue(makeThread(settled), NOW, 31)).toBe(true);
expect(isAutoArchiveDue(makeThread(settled), NOW, 32)).toBe(false);
expect(isAutoArchiveDue(makeThread(settled), NOW, null)).toBe(false);
});

it("leaves active, archived, and live threads alone", () => {
expect(isAutoArchiveDue(makeThread({ ...settled, settledOverride: "active" }), NOW, 1)).toBe(
false,
);
expect(isAutoArchiveDue(makeThread({ ...settled, archivedAt: NOW }), NOW, 1)).toBe(false);
const session = {
threadId: ThreadId.make("thread-1"),
status: "running",
providerName: "codex",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW,
} as const;
expect(isAutoArchiveDue(makeThread({ ...settled, session }), NOW, 1)).toBe(false);
});
});
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/ThreadSettlementPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,18 @@ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now:
Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt);
return wokeOnError || wokeOnCompletion;
}

/** Whether a settled thread has stayed settled long enough to archive. */
export function isAutoArchiveDue(
thread: Pick<
OrchestrationThreadShell,
"archivedAt" | "settledOverride" | "settledAt" | "session"
>,
now: string,
autoArchiveAfterDays: number | null,
): boolean {
if (autoArchiveAfterDays === null || thread.archivedAt !== null) return false;
if (thread.settledOverride !== "settled" || thread.settledAt === null) return false;
if (thread.session?.status === "starting" || thread.session?.status === "running") return false;
return Date.parse(thread.settledAt) <= Date.parse(now) - autoArchiveAfterDays * DAY_MS;
}
58 changes: 58 additions & 0 deletions apps/server/src/orchestration/ThreadSettlementReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
const domainEvents = yield* PubSub.unbounded<OrchestrationEvent>();
const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
const archivedThreadIds = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
const branchCalls = yield* Ref.make<
ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
>([]);
Expand Down Expand Up @@ -232,6 +233,11 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
});

const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
if (command.type === "thread.auto-archive") {
return Ref.update(archivedThreadIds, (ids) => [...ids, command.threadId]).pipe(
Effect.as({ sequence: 1 }),
);
}
if (command.type !== "thread.auto-settle") {
return Effect.die(new Error(`Unexpected command: ${command.type}`));
}
Expand Down Expand Up @@ -294,6 +300,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
snapshotReads,
settingsReads,
commands,
archivedThreadIds,
branchCalls,
summaryCalls,
summaryRecovery,
Expand Down Expand Up @@ -651,6 +658,57 @@ describe("ThreadSettlementReactor", () => {
),
);

it.effect("archives threads settled longer than the configured days", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const keptProject = ProjectId.make("kept-project");
const longSettled = {
settledOverride: "settled",
settledAt: "2026-07-01T00:00:00.000Z",
} as const;
const fixture = yield* makeHarness({
snapshot: makeSnapshot(
[
makeThread("old-settled", longSettled),
makeThread("recently-settled", {
settledOverride: "settled",
settledAt: "2026-08-20T00:00:00.000Z",
}),
makeThread("old-active", { settledAt: "2026-07-01T00:00:00.000Z" }),
makeThread("kept-project-settled", { ...longSettled, projectId: keptProject }),
],
[makeProject(), makeProject(keptProject, "/workspace/kept")],
),
settings: {
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleOnMerge: false,
projectSettingsOverrides: { [keptProject]: { sidebarAutoArchiveAfterDays: null } },
},
});

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* reactor.start();
yield* Queue.take(fixture.settingsReads);
yield* Deferred.succeed(fixture.activation, undefined);
yield* Queue.take(fixture.settingsReads);
yield* reactor.drain;
// Off by default: nothing archives until the setting is turned on.
assert.deepStrictEqual(yield* Ref.get(fixture.archivedThreadIds), []);

yield* fixture.updateSettings({ sidebarAutoArchiveAfterDays: 30 });
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
assert.deepStrictEqual(yield* Ref.get(fixture.archivedThreadIds), [
ThreadId.make("old-settled"),
]);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("a project override settles only that project's inactive threads", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading