Skip to content
Open
24 changes: 23 additions & 1 deletion apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ import {
refreshArchivedThreadsForEnvironment,
useArchivedThreadSnapshots,
} from "./useArchivedThreadSnapshots";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { scopedThreadKey } from "../../lib/scopedEntities";

export function ArchivedThreadsRouteScreen() {
const { expand } = useClerkSettingsSheetDetent();
const { savedConnectionsById } = useSavedRemoteConnections();
const [searchQuery, setSearchQuery] = useState("");
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
const [sortOrder, setSortOrder] = useState<ArchivedThreadSortOrder>("newest");
const [unarchivingThreadKeys, setUnarchivingThreadKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const environments = useMemo<ReadonlyArray<ArchivedThreadsHeaderEnvironment>>(
() =>
Arr.sort(
Expand Down Expand Up @@ -67,6 +72,22 @@ export function ArchivedThreadsRouteScreen() {
);
const { unarchiveThread, confirmDeleteThread } =
useArchivedThreadListActions(refreshChangedEnvironment);
const handleUnarchiveThread = useCallback(
async (thread: EnvironmentThreadShell) => {
const threadKey = scopedThreadKey(thread.environmentId, thread.id);
setUnarchivingThreadKeys((current) => new Set(current).add(threadKey));
try {
await unarchiveThread(thread);
} finally {
setUnarchivingThreadKeys((current) => {
const next = new Set(current);
next.delete(threadKey);
return next;
});
}
},
[unarchiveThread],
);

useFocusEffect(
useCallback(() => {
Expand All @@ -86,10 +107,11 @@ export function ArchivedThreadsRouteScreen() {
onRefresh={refresh}
onSearchQueryChange={setSearchQuery}
onSortOrderChange={setSortOrder}
onUnarchiveThread={unarchiveThread}
onUnarchiveThread={(thread) => void handleUnarchiveThread(thread)}
searchQuery={searchQuery}
selectedEnvironmentId={selectedEnvironmentId}
sortOrder={sortOrder}
unarchivingThreadKeys={unarchivingThreadKeys}
/>
);
}
24 changes: 21 additions & 3 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
import { scopedThreadKey } from "../../lib/scopedEntities";

export interface ArchivedThreadsHeaderEnvironment {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -383,6 +384,7 @@ function ArchivedThreadRow(props: {
readonly environmentLabel: string | null;
readonly isFirst: boolean;
readonly isLast: boolean;
readonly isUnarchiving: boolean;
readonly onDelete: () => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
Expand All @@ -400,6 +402,7 @@ function ArchivedThreadRow(props: {
const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
Boolean(part),
);
const onDelete = props.isUnarchiving ? () => undefined : props.onDelete;
return (
<ThreadSwipeable
backgroundColor={cardColor}
Expand All @@ -412,15 +415,16 @@ function ArchivedThreadRow(props: {
borderBottomRightRadius: props.isLast ? 20 : 0,
overflow: "hidden",
}}
enabled={!props.isUnarchiving}
fullSwipeWidth={windowWidth - 32}
onDelete={props.onDelete}
onDelete={onDelete}
onSwipeableClose={props.onSwipeableClose}
onSwipeableWillOpen={props.onSwipeableWillOpen}
primaryAction={{
accessibilityLabel: `Unarchive ${props.thread.title}`,
icon: "arrow.uturn.backward",
label: "Unarchive",
onPress: props.onUnarchive,
onPress: props.isUnarchiving ? () => undefined : props.onUnarchive,
}}
simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
threadTitle={props.thread.title}
Expand All @@ -434,7 +438,16 @@ function ArchivedThreadRow(props: {
}}
>
<View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
<SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
{props.isUnarchiving ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<SymbolView
name="archivebox.fill"
size={15}
tintColor={iconColor}
type="monochrome"
/>
)}
</View>

<View className="min-w-0 flex-1 gap-1">
Expand Down Expand Up @@ -500,6 +513,7 @@ export function ArchivedThreadsScreen(props: {
readonly onSearchQueryChange: (query: string) => void;
readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
readonly unarchivingThreadKeys: ReadonlySet<string>;
}) {
const { onDeleteThread, onUnarchiveThread } = props;
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
Expand Down Expand Up @@ -564,6 +578,9 @@ export function ArchivedThreadsScreen(props: {
environmentLabel={item.environmentLabel}
isFirst={item.isFirst}
isLast={item.isLast}
isUnarchiving={props.unarchivingThreadKeys.has(
scopedThreadKey(item.thread.environmentId, item.thread.id),
)}
onDelete={() => onDeleteThread(item.thread)}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
Expand All @@ -579,6 +596,7 @@ export function ArchivedThreadsScreen(props: {
handleSwipeableWillOpen,
onDeleteThread,
onUnarchiveThread,
props.unarchivingThreadKeys,
],
);
const listEmptyComponent = useMemo(() => {
Expand Down
6 changes: 2 additions & 4 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function useThreadListActions(): {
export function useArchivedThreadListActions(
onCompleted: (thread: EnvironmentThreadShell) => void,
): {
readonly unarchiveThread: (thread: EnvironmentThreadShell) => void;
readonly unarchiveThread: (thread: EnvironmentThreadShell) => Promise<void>;
readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
} {
const handleCompleted = useCallback(
Expand All @@ -140,9 +140,7 @@ export function useArchivedThreadListActions(
);
const executeAction = useThreadActionExecutor(handleCompleted);
const unarchiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("unarchive", thread);
},
(thread: EnvironmentThreadShell) => executeAction("unarchive", thread),
[executeAction],
);
const confirmDeleteThread = useConfirmDeleteThread(executeAction);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type StartupPresentation = typeof StartupPresentation.Type;
export interface ServerDerivedPaths {
readonly stateDir: string;
readonly dbPath: string;
readonly archiveDbPath: string;
readonly keybindingsConfigPath: string;
readonly settingsPath: string;
readonly providerStatusCacheDir: string;
Expand Down Expand Up @@ -95,13 +96,15 @@ export const deriveServerPaths = Effect.fn(function* (
const { join } = yield* Path.Path;
const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata");
const dbPath = join(stateDir, "state.sqlite");
const archiveDbPath = join(stateDir, "archive.sqlite");
const attachmentsDir = join(stateDir, "attachments");
const logsDir = join(stateDir, "logs");
const providerLogsDir = join(logsDir, "provider");
const providerStatusCacheDir = join(baseDir, "caches");
return {
stateDir,
dbPath,
archiveDbPath,
keybindingsConfigPath: join(stateDir, "keybindings.json"),
settingsPath: join(stateDir, "settings.json"),
providerStatusCacheDir,
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type OrchestrationEvent,
ProviderInstanceId,
} from "@t3tools/contracts";
import { it as effectIt } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
} from "../Services/ProjectionPipeline.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import { ServerConfig } from "../../config.ts";
import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts";

const asProjectId = (value: string): ProjectId => ProjectId.make(value);
const asMessageId = (value: string): MessageId => MessageId.make(value);
Expand Down Expand Up @@ -864,6 +866,122 @@ describe("OrchestrationEngine", () => {
await runtime.dispose();
});

effectIt.effect("rolls restored cold storage back when unarchive dispatch fails", () => {
type StoredEvent =
ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any>
? A
: never;
const events: StoredEvent[] = [];
const rolledBackThreadIds: ThreadId[] = [];
const finishedThreadIds: ThreadId[] = [];
let nextSequence = 1;
let restoreOnUnarchive = false;

const flakyStore: OrchestrationEventStoreShape = {
append(event) {
if (event.commandId === CommandId.make("cmd-cold-unarchive-fail")) {
return Effect.fail(
new PersistenceSqlError({
operation: "test.append",
detail: "unarchive append failed",
}),
);
}
const savedEvent = { ...event, sequence: nextSequence } as StoredEvent;
nextSequence += 1;
events.push(savedEvent);
return Effect.succeed(savedEvent);
},
readFromSequence(sequenceExclusive) {
return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive));
},
readAll() {
return Stream.fromIterable(events);
},
};
const coldStorage: ThreadColdStorage["Service"] = {
archiveThread: () => Effect.void,
restoreTree: () => Effect.succeed(restoreOnUnarchive),
rollbackRestoreTree: (threadId) =>
Effect.sync(() => {
rolledBackThreadIds.push(threadId);
}),
finishRestoreTree: (threadId) =>
Effect.sync(() => {
finishedThreadIds.push(threadId);
}),
deleteThread: () => Effect.void,
removeProviderLogs: () => Effect.void,
compactLegacyStorage: Effect.void,
listPendingArchiveThreadIds: Effect.succeed([]),
listPendingDeleteThreadIds: Effect.succeed([]),
};
const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-orchestration-engine-test-",
});
const testLayer = OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
Layer.provide(Layer.succeed(ThreadColdStorage, coldStorage)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
Layer.provideMerge(ServerConfigLayer),
Layer.provideMerge(NodeServices.layer),
);
return Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const createdAt = now();

yield* engine.dispatch({
type: "project.create",
commandId: CommandId.make("cmd-cold-project-create"),
projectId: asProjectId("project-cold-rollback"),
title: "Cold rollback project",
workspaceRoot: "/tmp/project-cold-rollback",
defaultModelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
createdAt,
});
yield* engine.dispatch({
type: "thread.create",
commandId: CommandId.make("cmd-cold-thread-create"),
threadId: ThreadId.make("thread-cold-rollback"),
projectId: asProjectId("project-cold-rollback"),
title: "Cold rollback thread",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt,
});
yield* engine.dispatch({
type: "thread.archive",
commandId: CommandId.make("cmd-cold-thread-archive"),
threadId: ThreadId.make("thread-cold-rollback"),
});
restoreOnUnarchive = true;

const unarchiveFailure = yield* Effect.flip(
engine.dispatch({
type: "thread.unarchive",
commandId: CommandId.make("cmd-cold-unarchive-fail"),
threadId: ThreadId.make("thread-cold-rollback"),
}),
);
expect(unarchiveFailure.message).toContain("unarchive append failed");
expect(rolledBackThreadIds).toEqual([ThreadId.make("thread-cold-rollback")]);
expect(finishedThreadIds).toEqual([]);
}).pipe(Effect.provide(testLayer));
});

it("rolls back all events for a multi-event command when projection fails mid-dispatch", async () => {
let shouldFailRequestedProjection = true;
const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = {
Expand Down
Loading
Loading