From bb40e9a3f97e2bd3c9d1193ef9f85a3a908bd4e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 14 Jul 2026 12:38:48 +0100 Subject: [PATCH 01/63] Add cold storage for archived conversations --- .../archive/ArchivedThreadsRouteScreen.tsx | 24 +- .../archive/ArchivedThreadsScreen.tsx | 20 +- .../src/features/home/useThreadListActions.ts | 6 +- apps/server/src/config.ts | 3 + .../Layers/OrchestrationEngine.ts | 30 ++ .../Layers/ThreadColdStorage.test.ts | 131 +++++ .../orchestration/Layers/ThreadColdStorage.ts | 491 ++++++++++++++++++ .../Layers/ThreadDeletionReactor.ts | 82 ++- .../Services/ThreadColdStorage.ts | 38 ++ .../Services/ThreadDeletionReactor.ts | 4 +- apps/server/src/persistence/Migrations.ts | 4 + .../033_034_ThreadStorageLifecycle.test.ts | 69 +++ .../Migrations/033_ThreadColdArchive.ts | 68 +++ .../034_DeletedThreadCleanupQueue.ts | 24 + .../src/provider/Layers/EventNdjsonLogger.ts | 19 + .../src/provider/acp/AcpNativeLogging.test.ts | 1 + apps/server/src/server.ts | 7 +- apps/web/src/components/Sidebar.tsx | 15 +- .../components/settings/SettingsPanels.tsx | 93 ++-- apps/web/src/hooks/useThreadActions.ts | 23 +- apps/web/src/optimisticThreadArchiveStore.ts | 33 ++ 21 files changed, 1114 insertions(+), 71 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts create mode 100644 apps/server/src/orchestration/Layers/ThreadColdStorage.ts create mode 100644 apps/server/src/orchestration/Services/ThreadColdStorage.ts create mode 100644 apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_ThreadColdArchive.ts create mode 100644 apps/server/src/persistence/Migrations/034_DeletedThreadCleanupQueue.ts create mode 100644 apps/web/src/optimisticThreadArchiveStore.ts diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index c2381ef2580..fc20c581ad9 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -16,6 +16,8 @@ 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(); @@ -23,6 +25,9 @@ export function ArchivedThreadsRouteScreen() { const [searchQuery, setSearchQuery] = useState(""); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); const [sortOrder, setSortOrder] = useState("newest"); + const [unarchivingThreadKeys, setUnarchivingThreadKeys] = useState>( + () => new Set(), + ); const environments = useMemo>( () => Arr.sort( @@ -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(() => { @@ -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} /> ); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 916802e9faf..a36d39b73ea 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -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; @@ -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; @@ -420,7 +422,7 @@ function ArchivedThreadRow(props: { 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} @@ -434,7 +436,16 @@ function ArchivedThreadRow(props: { }} > - + {props.isUnarchiving ? ( + + ) : ( + + )} @@ -500,6 +511,7 @@ export function ArchivedThreadsScreen(props: { readonly onSearchQueryChange: (query: string) => void; readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; + readonly unarchivingThreadKeys: ReadonlySet; }) { const { onDeleteThread, onUnarchiveThread } = props; const openSwipeableRef = useRef(null); @@ -564,6 +576,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} @@ -579,6 +594,7 @@ export function ArchivedThreadsScreen(props: { handleSwipeableWillOpen, onDeleteThread, onUnarchiveThread, + props.unarchivingThreadKeys, ], ); const listEmptyComponent = useMemo(() => { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..858c473c3f0 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -129,7 +129,7 @@ export function useThreadListActions(): { export function useArchivedThreadListActions( onCompleted: (thread: EnvironmentThreadShell) => void, ): { - readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; + readonly unarchiveThread: (thread: EnvironmentThreadShell) => Promise; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; } { const handleCompleted = useCallback( @@ -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); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2608ccc16ae..9bc8518b609 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -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; @@ -95,6 +96,7 @@ 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"); @@ -102,6 +104,7 @@ export const deriveServerPaths = Effect.fn(function* ( return { stateDir, dbPath, + archiveDbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 9598d1c3ef5..6fab829441e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -45,6 +45,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; +import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); @@ -82,6 +83,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const threadColdStorage = yield* Effect.serviceOption(ThreadColdStorage); const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -150,6 +152,24 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + const unarchiveThreadId = + envelope.command.type === "thread.unarchive" ? envelope.command.threadId : null; + if (unarchiveThreadId !== null && Option.isSome(threadColdStorage)) { + const restored = yield* threadColdStorage.value.restoreTree(unarchiveThreadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: "Failed to restore the archived conversation.", + cause, + }), + ), + ); + if (restored) { + commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); + } + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, @@ -228,6 +248,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); } } + if (unarchiveThreadId !== null && Option.isSome(threadColdStorage)) { + yield* threadColdStorage.value.finishRestoreTree(unarchiveThreadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to finalize restored archive bundle", { + threadId: unarchiveThreadId, + cause: Cause.pretty(cause), + }), + ), + ); + } return { sequence: committedCommand.lastSequence }; }).pipe(Effect.withSpan(`orchestration.command.${envelope.command.type}`)), ).pipe( diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts new file mode 100644 index 00000000000..15f16175c63 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -0,0 +1,131 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +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 SqlClient from "effect/unstable/sql/SqlClient"; + +import * as ServerConfig from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; +import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; + +const layer = it.layer( + ThreadColdStorageLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-cold-storage-" })), + Layer.provideMerge(NodeServices.layer), + ), +); + +layer("ThreadColdStorage", (it) => { + it.effect("compresses conversation data, destroys logs, restores content, and hard-deletes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-cold"); + const attachmentName = "thread-cold-00000000-0000-4000-8000-000000000001.png"; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, created_at, updated_at, archived_at + ) VALUES ( + ${threadId}, 'project-1', 'Cold thread', + '{"instanceId":"codex","model":"gpt-5.5","options":[]}', + 'full-access', 'default', '2026-07-01T00:00:00.000Z', + '2026-07-02T00:00:00.000Z', '2026-07-02T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-1', ${threadId}, NULL, 'user', 'keep this conversation', + '[{"type":"image","id":"thread-cold-00000000-0000-4000-8000-000000000001","name":"image.png","mimeType":"image/png"}]', + 0, '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, command_id, causation_event_id, correlation_id, + actor_kind, payload_json, metadata_json + ) VALUES ( + 'event-1', 'thread', ${threadId}, 1, 'thread.created', + '2026-07-01T00:00:00.000Z', 'command-1', NULL, 'command-1', + 'system', '{}', '{}' + ) + `; + + const attachmentPath = path.join(config.attachmentsDir, attachmentName); + const providerLogPath = path.join(config.providerLogsDir, "thread-cold.log"); + const rotatedProviderLogPath = `${providerLogPath}.1`; + yield* fs.writeFileString(attachmentPath, "image bytes"); + yield* fs.writeFileString(providerLogPath, "diagnostic"); + yield* fs.writeFileString(rotatedProviderLogPath, "old diagnostic"); + + yield* storage.archiveThread(threadId); + + const archivedMessageCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const archivedEventCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_events WHERE stream_id = ${threadId} + `; + const shellCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_threads WHERE thread_id = ${threadId} + `; + const manifest = yield* sql<{ readonly status: string; readonly compressedBytes: number }>` + SELECT status, compressed_bytes AS "compressedBytes" + FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.strictEqual(archivedMessageCount[0]?.count, 0); + assert.strictEqual(archivedEventCount[0]?.count, 0); + assert.strictEqual(shellCount[0]?.count, 1); + assert.strictEqual(manifest[0]?.status, "cold"); + assert.isAbove(manifest[0]?.compressedBytes ?? 0, 0); + assert.isFalse(yield* fs.exists(attachmentPath)); + assert.isFalse(yield* fs.exists(providerLogPath)); + assert.isFalse(yield* fs.exists(rotatedProviderLogPath)); + + assert.isTrue(yield* storage.restoreTree(threadId)); + const restoredMessages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(restoredMessages, [{ text: "keep this conversation" }]); + assert.strictEqual(yield* fs.readFileString(attachmentPath), "image bytes"); + assert.isFalse(yield* fs.exists(providerLogPath)); + + yield* storage.finishRestoreTree(threadId); + const remainingManifestCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.strictEqual(remainingManifestCount[0]?.count, 0); + + yield* storage.deleteThread(threadId); + const remainingShellCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_threads WHERE thread_id = ${threadId} + `; + const remainingMessageCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + assert.strictEqual(remainingShellCount[0]?.count, 0); + assert.strictEqual(remainingMessageCount[0]?.count, 0); + assert.isFalse(yield* fs.exists(attachmentPath)); + + yield* storage.compactLegacyStorage; + const maintenance = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_storage_maintenance + WHERE task = 'compact-legacy-thread-storage' + `; + assert.deepStrictEqual(maintenance, [{ status: "complete" }]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts new file mode 100644 index 00000000000..3602e5fb4cd --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -0,0 +1,491 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeUtil from "node:util"; +import * as NodeZlib from "node:zlib"; + +import { ThreadId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +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 SqlClient from "effect/unstable/sql/SqlClient"; + +import { parseAttachmentIdFromRelativePath } from "../../attachmentStore.ts"; +import { + parseThreadSegmentFromAttachmentId, + toSafeThreadAttachmentSegment, +} from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ThreadColdStorage, + ThreadColdStorageError, + type ThreadColdStorageShape, +} from "../Services/ThreadColdStorage.ts"; + +const gzipAsync = NodeUtil.promisify(NodeZlib.gzip); +const gunzipAsync = NodeUtil.promisify(NodeZlib.gunzip); +const ARCHIVE_SCHEMA = "cold_archive"; +const ARCHIVE_VERSION = 1; +const ROW_CHUNK_SIZE = 250; + +type SqlRow = Record; + +class ArchiveCodecError extends Data.TaggedError("ArchiveCodecError")<{ + readonly cause: unknown; +}> {} + +const THREAD_TABLES = [ + ["orchestration_events", "stream_id"], + ["orchestration_command_receipts", "aggregate_id"], + ["checkpoint_diff_blobs", "thread_id"], + ["provider_session_runtime", "thread_id"], + ["projection_thread_messages", "thread_id"], + ["projection_thread_activities", "thread_id"], + ["projection_thread_sessions", "thread_id"], + ["projection_turns", "thread_id"], + ["projection_pending_approvals", "thread_id"], + ["projection_thread_proposed_plans", "thread_id"], +] as const; + +function storageError(operation: string, threadId: string, cause: unknown) { + return new ThreadColdStorageError({ operation, threadId, cause }); +} + +function encodeRows(rows: ReadonlyArray): Uint8Array { + return Buffer.from(JSON.stringify(rows), "utf8"); +} + +function decodeRows(data: Uint8Array): ReadonlyArray { + return JSON.parse(Buffer.from(data).toString("utf8")) as ReadonlyArray; +} + +const compress = (data: Uint8Array) => + Effect.tryPromise({ + try: () => gzipAsync(data), + catch: (cause) => new ArchiveCodecError({ cause }), + }).pipe(Effect.map((value) => new Uint8Array(value))); + +const decompress = (data: Uint8Array) => + Effect.tryPromise({ + try: () => gunzipAsync(data), + catch: (cause) => new ArchiveCodecError({ cause }), + }).pipe(Effect.map((value) => new Uint8Array(value))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + + yield* sql.unsafe(`ATTACH DATABASE ? AS ${ARCHIVE_SCHEMA}`, [config.archiveDbPath]); + yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.auto_vacuum = INCREMENTAL`); + yield* sql.unsafe(` + CREATE TABLE IF NOT EXISTS ${ARCHIVE_SCHEMA}.archive_threads ( + thread_id TEXT PRIMARY KEY, + root_thread_id TEXT NOT NULL, + archive_version INTEGER NOT NULL, + archived_at TEXT NOT NULL, + original_bytes INTEGER NOT NULL, + compressed_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL + ) + `); + yield* sql.unsafe(` + CREATE TABLE IF NOT EXISTS ${ARCHIVE_SCHEMA}.archive_thread_chunks ( + thread_id TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + kind TEXT NOT NULL, + row_count INTEGER NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (thread_id, chunk_index) + ) + `); + yield* sql.unsafe(` + CREATE INDEX IF NOT EXISTS ${ARCHIVE_SCHEMA}.idx_archive_thread_chunks_thread + ON archive_thread_chunks(thread_id, chunk_index) + `); + + const attachmentEntriesForThread = Effect.fn("attachmentEntriesForThread")(function* ( + threadId: string, + ) { + const segment = toSafeThreadAttachmentSegment(threadId); + if (!segment) return [] as string[]; + const entries = yield* fs + .readDirectory(config.attachmentsDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as string[])); + return entries.filter((entry) => { + const attachmentId = parseAttachmentIdFromRelativePath(entry); + return attachmentId !== null && parseThreadSegmentFromAttachmentId(attachmentId) === segment; + }); + }); + + const removeAttachments = Effect.fn("removeThreadAttachments")(function* (threadId: string) { + const entries = yield* attachmentEntriesForThread(threadId); + yield* Effect.forEach( + entries, + (entry) => fs.remove(path.join(config.attachmentsDir, entry), { force: true }), + { concurrency: 4, discard: true }, + ); + }); + + const removeProviderLogsImpl = Effect.fn("removeProviderLogs")(function* (threadId: string) { + const segment = toSafeThreadAttachmentSegment(threadId); + if (!segment) return; + const baseName = `${segment}.log`; + const entries = yield* fs + .readDirectory(config.providerLogsDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as string[])); + yield* Effect.forEach( + entries.filter( + (entry) => + entry === baseName || new RegExp(`^${baseName.replace(".", "\\.")}\\.\\d+$`).test(entry), + ), + (entry) => fs.remove(path.join(config.providerLogsDir, entry), { force: true }), + { concurrency: 4, discard: true }, + ); + }); + + const reclaimFreePages = Effect.fn("reclaimThreadStorageFreePages")(function* () { + yield* sql.unsafe("PRAGMA main.incremental_vacuum(2048)"); + yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.incremental_vacuum(2048)`); + }); + + const insertChunk = Effect.fn("insertArchiveChunk")(function* (input: { + readonly threadId: string; + readonly chunkIndex: number; + readonly kind: string; + readonly rowCount: number; + readonly data: Uint8Array; + }) { + yield* sql.unsafe( + `INSERT INTO ${ARCHIVE_SCHEMA}.archive_thread_chunks + (thread_id, chunk_index, kind, row_count, data) + VALUES (?, ?, ?, ?, ?)`, + [input.threadId, input.chunkIndex, input.kind, input.rowCount, input.data], + ); + }); + + const archiveImpl = Effect.fn("archiveThreadImpl")(function* (threadId: ThreadId) { + const manifestRows = (yield* sql.unsafe( + `SELECT root_thread_id, archived_at, status + FROM thread_archive_manifests + WHERE thread_id = ?`, + [threadId], + )) as ReadonlyArray; + const threadRows = (yield* sql.unsafe( + `SELECT thread_id AS root_thread_id, archived_at + FROM projection_threads + WHERE thread_id = ? AND deleted_at IS NULL AND archived_at IS NOT NULL`, + [threadId], + )) as ReadonlyArray; + const source = manifestRows[0] ?? threadRows[0]; + if (!source) return; + if (source.status === "cold") return; + const rootThreadId = String(source.root_thread_id ?? threadId); + const archivedAt = String(source.archived_at ?? DateTime.formatIso(yield* DateTime.now)); + + yield* sql.unsafe( + `INSERT INTO thread_archive_manifests + (thread_id, root_thread_id, status, archive_version, archived_at, updated_at, error) + VALUES (?, ?, 'archiving', ?, ?, CURRENT_TIMESTAMP, NULL) + ON CONFLICT(thread_id) DO UPDATE SET + root_thread_id = excluded.root_thread_id, + status = 'archiving', + archive_version = excluded.archive_version, + archived_at = excluded.archived_at, + updated_at = CURRENT_TIMESTAMP, + error = NULL`, + [threadId, rootThreadId, ARCHIVE_VERSION, archivedAt], + ); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, [ + threadId, + ]); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ + threadId, + ]); + + let chunkIndex = 0; + let originalBytes = 0; + let compressedBytes = 0; + for (const [table, keyColumn] of THREAD_TABLES) { + let lastRowId = 0; + while (true) { + const rows = (yield* sql.unsafe( + `SELECT rowid AS __archive_rowid, * + FROM ${table} + WHERE ${keyColumn} = ? AND rowid > ? + ORDER BY rowid ASC + LIMIT ${ROW_CHUNK_SIZE}`, + [threadId, lastRowId], + )) as ReadonlyArray; + if (rows.length === 0) break; + const normalizedRows = rows.map((row) => { + const { __archive_rowid, ...stored } = row; + lastRowId = Number(__archive_rowid); + return stored; + }); + const encoded = encodeRows(normalizedRows); + const compressed = yield* compress(encoded); + yield* insertChunk({ + threadId, + chunkIndex, + kind: `table:${table}`, + rowCount: normalizedRows.length, + data: compressed, + }); + chunkIndex += 1; + originalBytes += encoded.byteLength; + compressedBytes += compressed.byteLength; + } + } + + const attachmentEntries = yield* attachmentEntriesForThread(threadId); + for (const entry of attachmentEntries) { + const bytes = yield* fs.readFile(path.join(config.attachmentsDir, entry)); + const compressed = yield* compress(bytes); + yield* insertChunk({ + threadId, + chunkIndex, + kind: `attachment:${entry}`, + rowCount: 1, + data: compressed, + }); + chunkIndex += 1; + originalBytes += bytes.byteLength; + compressedBytes += compressed.byteLength; + } + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql.unsafe( + `INSERT INTO ${ARCHIVE_SCHEMA}.archive_threads + (thread_id, root_thread_id, archive_version, archived_at, original_bytes, + compressed_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + [threadId, rootThreadId, ARCHIVE_VERSION, archivedAt, originalBytes, compressedBytes], + ); + for (const [table, keyColumn] of [...THREAD_TABLES].toReversed()) { + yield* sql.unsafe(`DELETE FROM ${table} WHERE ${keyColumn} = ?`, [threadId]); + } + yield* sql.unsafe( + `UPDATE thread_archive_manifests + SET status = 'cold', original_bytes = ?, compressed_bytes = ?, + updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE thread_id = ?`, + [originalBytes, compressedBytes, threadId], + ); + }), + ); + + yield* removeAttachments(threadId); + yield* removeProviderLogsImpl(threadId); + yield* reclaimFreePages(); + }); + + const insertRows = Effect.fn("restoreArchiveRows")(function* ( + table: string, + rows: ReadonlyArray, + ) { + for (const row of rows) { + const columns = Object.keys(row); + if (columns.length === 0) continue; + const placeholders = columns.map(() => "?").join(", "); + yield* sql.unsafe( + `INSERT OR REPLACE INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`, + columns.map((column) => row[column]), + ); + } + }); + + const restoreThread = Effect.fn("restoreArchivedThread")(function* (threadId: ThreadId) { + const chunks = (yield* sql.unsafe( + `SELECT chunk_index, kind, data + FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks + WHERE thread_id = ? + ORDER BY chunk_index ASC`, + [threadId], + )) as ReadonlyArray; + if (chunks.length === 0) return false; + + yield* sql.withTransaction( + Effect.gen(function* () { + for (const chunk of chunks) { + const kind = String(chunk.kind); + const data = yield* decompress(chunk.data as Uint8Array); + if (!kind.startsWith("table:")) continue; + yield* insertRows(kind.slice("table:".length), decodeRows(data)); + } + yield* sql.unsafe( + `UPDATE thread_archive_manifests + SET status = 'restored', updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE thread_id = ?`, + [threadId], + ); + }), + ); + + for (const chunk of chunks) { + const kind = String(chunk.kind); + if (!kind.startsWith("attachment:")) continue; + const entry = kind.slice("attachment:".length); + if (entry.length === 0 || entry.includes("/") || entry.includes("\\")) continue; + const data = yield* decompress(chunk.data as Uint8Array); + yield* fs.writeFile(path.join(config.attachmentsDir, entry), data); + } + return true; + }); + + const resolveTreeRoot = Effect.fn("resolveArchiveTreeRoot")(function* (threadId: ThreadId) { + const rows = (yield* sql.unsafe( + `SELECT COALESCE( + (SELECT root_thread_id FROM thread_archive_manifests WHERE thread_id = ?), + (SELECT thread_id FROM projection_threads WHERE thread_id = ?), + ? + ) AS root_thread_id`, + [threadId, threadId, threadId], + )) as ReadonlyArray; + return ThreadId.make(String(rows[0]?.root_thread_id ?? threadId)); + }); + + const restoreTreeImpl = Effect.fn("restoreArchiveTreeImpl")(function* (threadId: ThreadId) { + const rootThreadId = yield* resolveTreeRoot(threadId); + const rows = (yield* sql.unsafe( + `SELECT thread_id + FROM thread_archive_manifests + WHERE root_thread_id = ? AND status IN ('cold', 'restored') + ORDER BY CASE WHEN thread_id = ? THEN 1 ELSE 0 END, thread_id ASC`, + [rootThreadId, rootThreadId], + )) as ReadonlyArray; + let restored = false; + for (const row of rows) { + restored = (yield* restoreThread(ThreadId.make(String(row.thread_id)))) || restored; + } + return restored; + }); + + const finishRestoreTreeImpl = Effect.fn("finishRestoreArchiveTreeImpl")(function* ( + threadId: ThreadId, + ) { + const rootThreadId = yield* resolveTreeRoot(threadId); + const rows = (yield* sql.unsafe( + `SELECT thread_id FROM thread_archive_manifests WHERE root_thread_id = ? AND status = 'restored'`, + [rootThreadId], + )) as ReadonlyArray; + yield* sql.withTransaction( + Effect.gen(function* () { + for (const row of rows) { + const restoredThreadId = String(row.thread_id); + yield* sql.unsafe( + `DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, + [restoredThreadId], + ); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ + restoredThreadId, + ]); + yield* sql.unsafe(`DELETE FROM thread_archive_manifests WHERE thread_id = ?`, [ + restoredThreadId, + ]); + } + }), + ); + yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.incremental_vacuum(2048)`); + }); + + const deleteImpl = Effect.fn("deleteThreadPermanentlyImpl")(function* (threadId: ThreadId) { + yield* sql.unsafe( + `INSERT OR IGNORE INTO thread_cleanup_queue (thread_id, reason, created_at) + VALUES (?, 'deleted', CURRENT_TIMESTAMP)`, + [threadId], + ); + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql.unsafe( + `UPDATE projection_thread_proposed_plans + SET implementation_thread_id = NULL + WHERE implementation_thread_id = ?`, + [threadId], + ); + yield* sql.unsafe( + `UPDATE projection_turns + SET source_proposed_plan_thread_id = NULL, source_proposed_plan_id = NULL + WHERE source_proposed_plan_thread_id = ?`, + [threadId], + ); + for (const [table, keyColumn] of [...THREAD_TABLES].toReversed()) { + yield* sql.unsafe(`DELETE FROM ${table} WHERE ${keyColumn} = ?`, [threadId]); + } + yield* sql.unsafe(`DELETE FROM projection_threads WHERE thread_id = ?`, [threadId]); + yield* sql.unsafe( + `DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, + [threadId], + ); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ + threadId, + ]); + yield* sql.unsafe(`DELETE FROM thread_archive_manifests WHERE thread_id = ?`, [threadId]); + yield* sql.unsafe(`DELETE FROM thread_cleanup_queue WHERE thread_id = ?`, [threadId]); + }), + ); + yield* removeAttachments(threadId); + yield* removeProviderLogsImpl(threadId); + yield* reclaimFreePages(); + }); + + const compactLegacyStorageImpl = Effect.fn("compactLegacyThreadStorage")(function* () { + const rows = (yield* sql.unsafe( + `SELECT status FROM thread_storage_maintenance + WHERE task = 'compact-legacy-thread-storage'`, + )) as ReadonlyArray; + if (rows[0]?.status === "complete") return; + + yield* sql.unsafe( + `UPDATE thread_storage_maintenance + SET status = 'running', updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE task = 'compact-legacy-thread-storage'`, + ); + yield* sql.unsafe("PRAGMA wal_checkpoint(TRUNCATE)"); + yield* sql.unsafe("PRAGMA main.auto_vacuum = INCREMENTAL"); + yield* sql.unsafe("VACUUM main"); + yield* reclaimFreePages(); + yield* sql.unsafe( + `UPDATE thread_storage_maintenance + SET status = 'complete', updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE task = 'compact-legacy-thread-storage'`, + ); + }); + + const wrap = (operation: string, threadId: ThreadId, effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => storageError(operation, threadId, cause))); + + const listIds = (query: string, operation: string) => + sql.unsafe(query).pipe( + Effect.map((rows) => + (rows as ReadonlyArray).map((row) => ThreadId.make(String(row.thread_id))), + ), + Effect.mapError((cause) => storageError(operation, "startup", cause)), + ); + + return { + archiveThread: (threadId) => wrap("archive", threadId, archiveImpl(threadId)), + restoreTree: (threadId) => wrap("restore", threadId, restoreTreeImpl(threadId)), + finishRestoreTree: (threadId) => + wrap("finish-restore", threadId, finishRestoreTreeImpl(threadId)), + deleteThread: (threadId) => wrap("delete", threadId, deleteImpl(threadId)), + removeProviderLogs: (threadId) => + wrap("remove-provider-logs", threadId, removeProviderLogsImpl(threadId)), + compactLegacyStorage: compactLegacyStorageImpl().pipe( + Effect.mapError((cause) => storageError("compact-legacy-storage", "startup", cause)), + ), + listPendingArchiveThreadIds: listIds( + `SELECT thread_id FROM thread_archive_manifests WHERE status IN ('pending', 'archiving') ORDER BY archived_at ASC, thread_id ASC`, + "list-pending-archives", + ), + listPendingDeleteThreadIds: listIds( + `SELECT thread_id FROM thread_cleanup_queue WHERE reason = 'deleted' ORDER BY created_at ASC, thread_id ASC`, + "list-pending-deletes", + ), + } satisfies ThreadColdStorageShape; +}); + +export const ThreadColdStorageLive = Layer.effect(ThreadColdStorage, make); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7d8a24069a3..4023dba8d8d 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -5,15 +5,22 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; import { ThreadDeletionReactor, type ThreadDeletionReactorShape, } from "../Services/ThreadDeletionReactor.ts"; type ThreadDeletedEvent = Extract; +type ThreadArchivedEvent = Extract; +type ThreadLifecycleJob = + | { readonly type: "archive"; readonly threadId: ThreadArchivedEvent["payload"]["threadId"] } + | { readonly type: "delete"; readonly threadId: ThreadDeletedEvent["payload"]["threadId"] } + | { readonly type: "compact-legacy-storage" }; export const logCleanupCauseUnlessInterrupted = ({ effect, @@ -40,6 +47,8 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const terminalManager = yield* TerminalManager.TerminalManager; + const threadColdStorage = yield* ThreadColdStorage; + const providerEventLoggers = yield* ProviderEventLoggers; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ @@ -55,39 +64,88 @@ const make = Effect.gen(function* () { threadId, }); - const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( - event: ThreadDeletedEvent, + const closeProviderLogWriters = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: Effect.all( + [providerEventLoggers.native, providerEventLoggers.canonical].flatMap((logger) => + logger?.closeThread ? [logger.closeThread(threadId)] : [], + ), + { discard: true }, + ), + message: "thread lifecycle cleanup skipped provider log writer close", + threadId, + }); + + const processLifecycleJob = Effect.fn("processThreadLifecycleJob")(function* ( + job: ThreadLifecycleJob, ) { - const { threadId } = event.payload; + if (job.type === "compact-legacy-storage") { + yield* threadColdStorage.compactLegacyStorage; + return; + } + const { threadId } = job; yield* stopProviderSession(threadId); yield* closeThreadTerminals(threadId); + yield* closeProviderLogWriters(threadId); + if (job.type === "archive") { + yield* threadColdStorage.archiveThread(threadId); + } else { + yield* threadColdStorage.deleteThread(threadId); + } }); - const processThreadDeletedSafely = (event: ThreadDeletedEvent) => - processThreadDeleted(event).pipe( + const processLifecycleJobSafely = (job: ThreadLifecycleJob) => + processLifecycleJob(job).pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); } - return Effect.logWarning("thread deletion reactor failed to process event", { - eventType: event.type, - threadId: event.payload.threadId, + return Effect.logWarning("thread lifecycle reactor failed to process job", { + lifecycleAction: job.type, + ...(job.type === "compact-legacy-storage" ? {} : { threadId: job.threadId }), cause: Cause.pretty(cause), }); }), ); - const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + const worker = yield* makeDrainableWorker(processLifecycleJobSafely); const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.deleted") { - return Effect.void; + if (event.type === "thread.deleted") { + return worker.enqueue({ type: "delete", threadId: event.payload.threadId }); + } + if (event.type === "thread.archived") { + return worker.enqueue({ type: "archive", threadId: event.payload.threadId }); } - return worker.enqueue(event); + return Effect.void; }), ); + + const pendingJobs = yield* Effect.all([ + threadColdStorage.listPendingDeleteThreadIds, + threadColdStorage.listPendingArchiveThreadIds, + ]).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to read pending thread storage migrations", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ); + if (pendingJobs === null) return; + const [pendingDeletes, pendingArchives] = pendingJobs; + yield* Effect.forEach( + pendingDeletes, + (threadId) => worker.enqueue({ type: "delete", threadId }), + { discard: true }, + ); + yield* Effect.forEach( + pendingArchives, + (threadId) => worker.enqueue({ type: "archive", threadId }), + { discard: true }, + ); + yield* worker.enqueue({ type: "compact-legacy-storage" }); }); return { diff --git a/apps/server/src/orchestration/Services/ThreadColdStorage.ts b/apps/server/src/orchestration/Services/ThreadColdStorage.ts new file mode 100644 index 00000000000..17380914ac6 --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadColdStorage.ts @@ -0,0 +1,38 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ThreadColdStorageError extends Schema.TaggedErrorClass()( + "ThreadColdStorageError", + { + operation: Schema.String, + threadId: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Cold thread storage failed during ${this.operation} for '${this.threadId}'.`; + } +} + +export interface ThreadColdStorageShape { + readonly archiveThread: (threadId: ThreadId) => Effect.Effect; + readonly restoreTree: (threadId: ThreadId) => Effect.Effect; + readonly finishRestoreTree: (threadId: ThreadId) => Effect.Effect; + readonly deleteThread: (threadId: ThreadId) => Effect.Effect; + readonly removeProviderLogs: (threadId: ThreadId) => Effect.Effect; + readonly compactLegacyStorage: Effect.Effect; + readonly listPendingArchiveThreadIds: Effect.Effect< + ReadonlyArray, + ThreadColdStorageError + >; + readonly listPendingDeleteThreadIds: Effect.Effect< + ReadonlyArray, + ThreadColdStorageError + >; +} + +export class ThreadColdStorage extends Context.Service()( + "t3/orchestration/Services/ThreadColdStorage", +) {} diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts index 7c6718965a6..7159e0fa72a 100644 --- a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -1,8 +1,8 @@ /** * ThreadDeletionReactor - Thread deletion cleanup reactor service interface. * - * Owns background workers that react to thread deletion domain events and - * perform best-effort runtime cleanup for provider sessions and terminals. + * Owns background workers that react to thread archive/delete domain events + * and perform runtime cleanup plus durable cold-storage lifecycle work. * * @module ThreadDeletionReactor */ diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..244aa9d00dd 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,8 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ThreadColdArchive.ts"; +import Migration0034 from "./Migrations/034_DeletedThreadCleanupQueue.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +91,8 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ThreadColdArchive", Migration0033], + [34, "DeletedThreadCleanupQueue", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts b/apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts new file mode 100644 index 00000000000..17619226a70 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("033-034 thread storage lifecycle migrations", (it) => { + it.effect("queues archived and deleted threads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 32 }); + + const insertThread = (input: { + readonly threadId: string; + readonly archivedAt: string | null; + readonly deletedAt: string | null; + }) => sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, created_at, updated_at, archived_at, deleted_at + ) VALUES ( + ${input.threadId}, 'project-1', ${input.threadId}, + '{"instanceId":"codex","model":"gpt-5.5","options":[]}', + 'full-access', 'default', '2026-07-01T00:00:00.000Z', + '2026-07-01T00:00:00.000Z', ${input.archivedAt}, ${input.deletedAt} + ) + `; + + yield* insertThread({ + threadId: "archived-thread", + archivedAt: "2026-07-02T00:00:00.000Z", + deletedAt: null, + }); + yield* insertThread({ + threadId: "deleted-thread", + archivedAt: null, + deletedAt: "2026-07-03T00:00:00.000Z", + }); + + yield* runMigrations({ toMigrationInclusive: 34 }); + + const manifests = yield* sql<{ + readonly threadId: string; + readonly rootThreadId: string; + readonly status: string; + }>` + SELECT thread_id AS "threadId", root_thread_id AS "rootThreadId", status + FROM thread_archive_manifests + ORDER BY thread_id + `; + assert.deepStrictEqual(manifests, [ + { + threadId: "archived-thread", + rootThreadId: "archived-thread", + status: "pending", + }, + ]); + + const cleanup = yield* sql<{ readonly threadId: string; readonly reason: string }>` + SELECT thread_id AS "threadId", reason FROM thread_cleanup_queue + `; + assert.deepStrictEqual(cleanup, [{ threadId: "deleted-thread", reason: "deleted" }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/033_ThreadColdArchive.ts b/apps/server/src/persistence/Migrations/033_ThreadColdArchive.ts new file mode 100644 index 00000000000..d10e7e5394b --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ThreadColdArchive.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Registers already-archived threads for conversion to cold storage. + * + * The migration deliberately only creates durable work records. Compression + * and filesystem I/O are performed by the background lifecycle worker after + * startup so a large existing archive cannot hold the schema migration or UI + * thread hostage. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS thread_archive_manifests ( + thread_id TEXT PRIMARY KEY, + root_thread_id TEXT NOT NULL, + status TEXT NOT NULL, + archive_version INTEGER NOT NULL DEFAULT 1, + archived_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + original_bytes INTEGER NOT NULL DEFAULT 0, + compressed_bytes INTEGER NOT NULL DEFAULT 0, + error TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_thread_archive_manifests_root_status + ON thread_archive_manifests(root_thread_id, status, archived_at, thread_id) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS thread_storage_maintenance ( + task TEXT PRIMARY KEY, + status TEXT NOT NULL, + updated_at TEXT NOT NULL, + error TEXT + ) + `; + + yield* sql` + INSERT OR IGNORE INTO thread_storage_maintenance (task, status, updated_at) + VALUES ('compact-legacy-thread-storage', 'pending', CURRENT_TIMESTAMP) + `; + + yield* sql` + INSERT OR IGNORE INTO thread_archive_manifests ( + thread_id, + root_thread_id, + status, + archive_version, + archived_at, + updated_at + ) + SELECT + thread_id, + thread_id, + 'pending', + 1, + archived_at, + CURRENT_TIMESTAMP + FROM projection_threads + WHERE archived_at IS NOT NULL + AND deleted_at IS NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/034_DeletedThreadCleanupQueue.ts b/apps/server/src/persistence/Migrations/034_DeletedThreadCleanupQueue.ts new file mode 100644 index 00000000000..24cc80ec44e --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_DeletedThreadCleanupQueue.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Queues legacy soft-deleted threads for the same permanent cleanup as new deletes. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS thread_cleanup_queue ( + thread_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT + ) + `; + + yield* sql` + INSERT OR IGNORE INTO thread_cleanup_queue (thread_id, reason, created_at) + SELECT thread_id, 'deleted', CURRENT_TIMESTAMP + FROM projection_threads + WHERE deleted_at IS NOT NULL + `; +}); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 8c20a4c1936..a4b80d78cf5 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -33,6 +33,7 @@ export type EventNdjsonStream = "native" | "canonical" | "orchestration"; export interface EventNdjsonLogger { readonly filePath: string; write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + closeThread?: (threadId: ThreadId) => Effect.Effect; close: () => Effect.Effect; } @@ -278,9 +279,27 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function ); }); + const closeThread = Effect.fn("closeThread")(function* (threadId: ThreadId) { + const threadSegment = resolveThreadSegment(threadId); + yield* SynchronizedRef.modifyEffect(stateRef, (state) => { + const writer = state.threadWriters.get(threadSegment); + if (!writer) { + return Effect.succeed([undefined, state] as const); + } + return writer.close().pipe( + Effect.map(() => { + const nextThreadWriters = new Map(state.threadWriters); + nextThreadWriters.delete(threadSegment); + return [undefined, { ...state, threadWriters: nextThreadWriters }] as const; + }), + ); + }); + }); + return { filePath, write, + closeThread, close, } satisfies EventNdjsonLogger; }); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 8c92d523aee..b2904cc7913 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -21,6 +21,7 @@ nodeServicesIt("ACP native logging", (it) => { const nativeEventLogger: EventNdjsonLogger = { filePath: "/tmp/provider-native.ndjson", write: (event) => Effect.sync(() => void records.push(event)), + closeThread: () => Effect.void, close: () => Effect.void, }; const makeLogger = yield* makeAcpNativeLoggerFactory(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..f3455a32a03 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -49,6 +49,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { ThreadColdStorageLive } from "./orchestration/Layers/ThreadColdStorage.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -279,9 +280,13 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ), ); +const OrchestrationLayerWithColdStorageLive = OrchestrationLayerLive.pipe( + Layer.provideMerge(ThreadColdStorageLive), +); + const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(OrchestrationLayerLive), + Layer.provideMerge(OrchestrationLayerWithColdStorageLive), ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb6b2752a17..2053c6a7287 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -92,6 +92,7 @@ import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; import { previewEnvironment } from "../state/preview"; +import { useOptimisticThreadArchiveStore } from "../optimisticThreadArchiveStore"; import { legacyProjectCwdPreferenceKey, resolveProjectExpanded, @@ -3107,6 +3108,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useProjects(); const sidebarThreads = useThreadShells(); + const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( + (state) => state.threadKeys, + ); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -3368,8 +3372,15 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], + () => + sidebarThreads.filter( + (thread) => + thread.archivedAt === null && + !optimisticallyArchivedThreadKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [optimisticallyArchivedThreadKeys, sidebarThreads], ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d56314..ab258959a73 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -11,7 +11,7 @@ import { type ProviderInstanceId, type ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, @@ -1423,6 +1423,9 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const [unarchivingThreadKeys, setUnarchivingThreadKeys] = useState>( + () => new Set(), + ); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1484,19 +1487,11 @@ export function ArchivedThreadsPanel() { return groups; }, [archivedSnapshots]); - const handleArchivedThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "unarchive") { + const handleUnarchiveThread = useCallback( + async (threadRef: ScopedThreadRef) => { + const threadKey = scopedThreadKey(threadRef); + setUnarchivingThreadKeys((current) => new Set(current).add(threadKey)); + try { const result = await unarchiveThread(threadRef); if (result._tag === "Success") { refreshArchivedThreads(); @@ -1510,6 +1505,31 @@ export function ArchivedThreadsPanel() { }), ); } + } finally { + setUnarchivingThreadKeys((current) => { + const next = new Set(current); + next.delete(threadKey); + return next; + }); + } + }, + [refreshArchivedThreads, unarchiveThread], + ); + + const handleArchivedThreadContextMenu = useCallback( + async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive", label: "Unarchive" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "unarchive") { + await handleUnarchiveThread(threadRef); return; } @@ -1529,7 +1549,7 @@ export function ArchivedThreadsPanel() { } } }, - [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], + [confirmAndDeleteThread, handleUnarchiveThread, refreshArchivedThreads], ); return ( @@ -1565,11 +1585,15 @@ export function ArchivedThreadsPanel() { title={project.name} icon={} > - {projectThreads.map((thread) => ( - { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const isUnarchiving = unarchivingThreadKeys.has(scopedThreadKey(threadRef)); + return ( + { event.preventDefault(); + if (isUnarchiving) return; void (async () => { const result = await settlePromise(() => handleArchivedThreadContextMenu( @@ -1607,35 +1631,22 @@ export function ArchivedThreadsPanel() { variant="outline" size="sm" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" + disabled={isUnarchiving} onClick={() => { - void (async () => { - const result = await unarchiveThread( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (result._tag === "Success") { - refreshArchivedThreads(); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unarchive thread", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); + void handleUnarchiveThread(threadRef); }} > - - Unarchive + {isUnarchiving ? ( + + ) : ( + + )} + {isUnarchiving ? "Unarchiving" : "Unarchive"} } /> - ))} + ); + })} )) )} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..ed049106b45 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -26,6 +26,10 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; +import { + optimisticallyHideArchivedThread, + revealOptimisticallyArchivedThread, +} from "../optimisticThreadArchiveStore"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -108,25 +112,31 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveResult = await archiveThreadMutation({ + optimisticallyHideArchivedThread(threadRef); + const archivePromise = archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); - if (archiveResult._tag === "Failure") { - return archiveResult; - } - if (shouldNavigateToDraft) { const navigationResult = await settlePromise(() => handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), ); if (navigationResult._tag === "Failure") { + revealOptimisticallyArchivedThread(threadRef); return navigationResult; } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); + } + + const archiveResult = await archivePromise; + if (archiveResult._tag === "Failure") { + revealOptimisticallyArchivedThread(threadRef); return archiveResult; } + // The domain event is published before the command acknowledgement, so + // the shell now owns visibility. Do not retain a local tombstone that + // could hide a later unarchive performed by another client. + revealOptimisticallyArchivedThread(threadRef); refreshArchivedThreadsForEnvironment(threadRef.environmentId); return archiveResult; }, @@ -135,6 +145,7 @@ export function useThreadActions() { const unarchiveThread = useCallback( async (target: ScopedThreadRef) => { + revealOptimisticallyArchivedThread(target); const result = await unarchiveThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, diff --git a/apps/web/src/optimisticThreadArchiveStore.ts b/apps/web/src/optimisticThreadArchiveStore.ts new file mode 100644 index 00000000000..b9a1951c9f6 --- /dev/null +++ b/apps/web/src/optimisticThreadArchiveStore.ts @@ -0,0 +1,33 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { create } from "zustand"; + +interface OptimisticThreadArchiveState { + readonly threadKeys: ReadonlySet; + readonly hide: (threadRef: ScopedThreadRef) => void; + readonly show: (threadRef: ScopedThreadRef) => void; +} + +export const useOptimisticThreadArchiveStore = create((set) => ({ + threadKeys: new Set(), + hide: (threadRef) => + set((state) => { + const next = new Set(state.threadKeys); + next.add(scopedThreadKey(threadRef)); + return { threadKeys: next }; + }), + show: (threadRef) => + set((state) => { + const next = new Set(state.threadKeys); + next.delete(scopedThreadKey(threadRef)); + return { threadKeys: next }; + }), +})); + +export function optimisticallyHideArchivedThread(threadRef: ScopedThreadRef): void { + useOptimisticThreadArchiveStore.getState().hide(threadRef); +} + +export function revealOptimisticallyArchivedThread(threadRef: ScopedThreadRef): void { + useOptimisticThreadArchiveStore.getState().show(threadRef); +} From b56f8f54dfa1dae559b206ec345fcafee8a31741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 14 Jul 2026 13:00:55 +0100 Subject: [PATCH 02/63] Harden archived thread lifecycle recovery --- .../archive/ArchivedThreadsScreen.tsx | 4 +- .../Layers/OrchestrationEngine.test.ts | 118 +++++++++++++++ .../Layers/OrchestrationEngine.ts | 32 ++++ .../Layers/ThreadColdStorage.test.ts | 137 ++++++++++++++++++ .../orchestration/Layers/ThreadColdStorage.ts | 56 ++++++- .../Services/ThreadColdStorage.ts | 1 + apps/web/src/hooks/useThreadActions.ts | 5 + 7 files changed, 345 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index a36d39b73ea..7f455002c96 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -402,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 ( ProjectId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); @@ -864,6 +866,122 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); + effectIt.effect("rolls restored cold storage back when unarchive dispatch fails", () => { + type StoredEvent = + ReturnType extends Effect.Effect + ? 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: ThreadColdStorageShape = { + 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 = { diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 6fab829441e..9e323e929ee 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -107,6 +107,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => { const dispatchStartSequence = commandReadModel.snapshotSequence; let processingStartedAtMs = 0; + let restoredUnarchiveThreadId: ThreadId | null = null; const aggregateRef = commandToAggregateRef(envelope.command); const baseMetricAttributes = { commandType: envelope.command.type, @@ -166,6 +167,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); if (restored) { + restoredUnarchiveThreadId = unarchiveThreadId; commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); } } @@ -291,6 +293,36 @@ const makeOrchestrationEngine = Effect.gen(function* () { return; } + if (restoredUnarchiveThreadId !== null && Option.isSome(threadColdStorage)) { + const rolledBack = yield* threadColdStorage.value + .rollbackRestoreTree(restoredUnarchiveThreadId) + .pipe( + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("failed to roll back restored archive bundle", { + threadId: restoredUnarchiveThreadId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(false)), + ), + ); + if (rolledBack) { + const refreshedReadModel = yield* projectionSnapshotQuery.getCommandReadModel().pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "failed to refresh orchestration read model after archive rollback", + { + threadId: restoredUnarchiveThreadId, + cause: Cause.pretty(cause), + }, + ).pipe(Effect.as(null)), + ), + ); + if (refreshedReadModel !== null) { + commandReadModel = refreshedReadModel; + } + } + } + const error = Cause.squash(exit.cause) as OrchestrationDispatchError; if (!isOrchestrationCommandPreviouslyRejectedError(error)) { yield* reconcileReadModelAfterDispatchFailure.pipe( diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 15f16175c63..528ec182954 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -12,6 +12,26 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; +const insertArchivedThread = Effect.fn("insertArchivedThreadTestFixture")(function* ( + threadId: ThreadId, + title: string, +) { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, created_at, updated_at, archived_at, parent_kind, + root_thread_id + ) VALUES ( + ${threadId}, 'project-1', ${title}, + '{"instanceId":"codex","model":"gpt-5.5","options":[]}', + 'full-access', 'default', '2026-07-01T00:00:00.000Z', + '2026-07-02T00:00:00.000Z', '2026-07-02T00:00:00.000Z', + 'root', ${threadId} + ) + `; +}); + const layer = it.layer( ThreadColdStorageLive.pipe( Layer.provideMerge(SqlitePersistenceMemory), @@ -103,6 +123,19 @@ layer("ThreadColdStorage", (it) => { assert.strictEqual(yield* fs.readFileString(attachmentPath), "image bytes"); assert.isFalse(yield* fs.exists(providerLogPath)); + yield* storage.rollbackRestoreTree(threadId); + const rolledBackMessages = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const rolledBackManifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(rolledBackMessages, [{ count: 0 }]); + assert.deepStrictEqual(rolledBackManifest, [{ status: "cold" }]); + + assert.isTrue(yield* storage.restoreTree(threadId)); + assert.strictEqual(yield* fs.readFileString(attachmentPath), "image bytes"); + yield* storage.finishRestoreTree(threadId); const remainingManifestCount = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} @@ -128,4 +161,108 @@ layer("ThreadColdStorage", (it) => { assert.deepStrictEqual(maintenance, [{ status: "complete" }]); }), ); + + it.effect("ignores traversal attachment entries while restoring", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-traversal"); + const attachmentName = "thread-traversal-00000000-0000-4000-8000-000000000001.png"; + + yield* insertArchivedThread(threadId, "Traversal thread"); + yield* fs.writeFileString(path.join(config.attachmentsDir, attachmentName), "image bytes"); + yield* storage.archiveThread(threadId); + yield* sql` + UPDATE cold_archive.archive_thread_chunks + SET kind = 'attachment:..' + WHERE thread_id = ${threadId} AND kind LIKE 'attachment:%' + `; + + assert.isTrue(yield* storage.restoreTree(threadId)); + const manifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(manifest, [{ status: "restored" }]); + }), + ); + + it.effect("retries archive cleanup without rebuilding deleted hot data", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-cleanup-retry"); + const providerLogPath = path.join(config.providerLogsDir, "thread-cleanup-retry.log"); + + yield* insertArchivedThread(threadId, "Cleanup retry thread"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-cleanup-retry', ${threadId}, NULL, 'user', 'preserve across cleanup retry', + '[]', 0, '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + yield* fs.makeDirectory(providerLogPath); + yield* fs.writeFileString(path.join(providerLogPath, "keep"), "force cleanup failure"); + + const archiveFailure = yield* Effect.flip(storage.archiveThread(threadId)); + assert.strictEqual(archiveFailure.operation, "archive"); + const pendingManifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(pendingManifest, [{ status: "cleanup_pending" }]); + assert.deepInclude(yield* storage.listPendingArchiveThreadIds, threadId); + + yield* fs.remove(providerLogPath, { recursive: true }); + yield* storage.archiveThread(threadId); + const coldManifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(coldManifest, [{ status: "cold" }]); + + assert.isTrue(yield* storage.restoreTree(threadId)); + const restoredMessages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(restoredMessages, [{ text: "preserve across cleanup retry" }]); + }), + ); + + it.effect("keeps the delete cleanup queue entry until external cleanup succeeds", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-delete-retry"); + const attachmentName = "thread-delete-retry-00000000-0000-4000-8000-000000000001.png"; + const attachmentPath = path.join(config.attachmentsDir, attachmentName); + + yield* insertArchivedThread(threadId, "Delete retry thread"); + yield* fs.makeDirectory(attachmentPath); + yield* fs.writeFileString(path.join(attachmentPath, "keep"), "force cleanup failure"); + + const deleteFailure = yield* Effect.flip(storage.deleteThread(threadId)); + assert.strictEqual(deleteFailure.operation, "delete"); + const pendingCleanup = yield* sql<{ readonly reason: string }>` + SELECT reason FROM thread_cleanup_queue WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(pendingCleanup, [{ reason: "deleted" }]); + + yield* fs.remove(attachmentPath, { recursive: true }); + yield* storage.deleteThread(threadId); + const completedCleanup = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM thread_cleanup_queue WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(completedCleanup, [{ count: 0 }]); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index 3602e5fb4cd..0ee4b626bf9 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -151,6 +151,20 @@ const make = Effect.gen(function* () { yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.incremental_vacuum(2048)`); }); + const completeArchiveCleanup = Effect.fn("completeThreadArchiveCleanup")(function* ( + threadId: ThreadId, + ) { + yield* removeAttachments(threadId); + yield* removeProviderLogsImpl(threadId); + yield* reclaimFreePages(); + yield* sql.unsafe( + `UPDATE thread_archive_manifests + SET status = 'cold', updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE thread_id = ? AND status = 'cleanup_pending'`, + [threadId], + ); + }); + const insertChunk = Effect.fn("insertArchiveChunk")(function* (input: { readonly threadId: string; readonly chunkIndex: number; @@ -182,6 +196,10 @@ const make = Effect.gen(function* () { const source = manifestRows[0] ?? threadRows[0]; if (!source) return; if (source.status === "cold") return; + if (source.status === "cleanup_pending") { + yield* completeArchiveCleanup(threadId); + return; + } const rootThreadId = String(source.root_thread_id ?? threadId); const archivedAt = String(source.archived_at ?? DateTime.formatIso(yield* DateTime.now)); @@ -270,7 +288,7 @@ const make = Effect.gen(function* () { } yield* sql.unsafe( `UPDATE thread_archive_manifests - SET status = 'cold', original_bytes = ?, compressed_bytes = ?, + SET status = 'cleanup_pending', original_bytes = ?, compressed_bytes = ?, updated_at = CURRENT_TIMESTAMP, error = NULL WHERE thread_id = ?`, [originalBytes, compressedBytes, threadId], @@ -278,9 +296,7 @@ const make = Effect.gen(function* () { }), ); - yield* removeAttachments(threadId); - yield* removeProviderLogsImpl(threadId); - yield* reclaimFreePages(); + yield* completeArchiveCleanup(threadId); }); const insertRows = Effect.fn("restoreArchiveRows")(function* ( @@ -329,7 +345,15 @@ const make = Effect.gen(function* () { const kind = String(chunk.kind); if (!kind.startsWith("attachment:")) continue; const entry = kind.slice("attachment:".length); - if (entry.length === 0 || entry.includes("/") || entry.includes("\\")) continue; + if ( + entry.length === 0 || + entry === "." || + entry === ".." || + entry.includes("/") || + entry.includes("\\") + ) { + continue; + } const data = yield* decompress(chunk.data as Uint8Array); yield* fs.writeFile(path.join(config.attachmentsDir, entry), data); } @@ -364,6 +388,22 @@ const make = Effect.gen(function* () { return restored; }); + const rollbackRestoreTreeImpl = Effect.fn("rollbackRestoreArchiveTreeImpl")(function* ( + threadId: ThreadId, + ) { + const rootThreadId = yield* resolveTreeRoot(threadId); + const rows = (yield* sql.unsafe( + `SELECT thread_id + FROM thread_archive_manifests + WHERE root_thread_id = ? AND status = 'restored' + ORDER BY CASE WHEN thread_id = ? THEN 1 ELSE 0 END, thread_id ASC`, + [rootThreadId, rootThreadId], + )) as ReadonlyArray; + for (const row of rows) { + yield* archiveImpl(ThreadId.make(String(row.thread_id))); + } + }); + const finishRestoreTreeImpl = Effect.fn("finishRestoreArchiveTreeImpl")(function* ( threadId: ThreadId, ) { @@ -424,12 +464,12 @@ const make = Effect.gen(function* () { threadId, ]); yield* sql.unsafe(`DELETE FROM thread_archive_manifests WHERE thread_id = ?`, [threadId]); - yield* sql.unsafe(`DELETE FROM thread_cleanup_queue WHERE thread_id = ?`, [threadId]); }), ); yield* removeAttachments(threadId); yield* removeProviderLogsImpl(threadId); yield* reclaimFreePages(); + yield* sql.unsafe(`DELETE FROM thread_cleanup_queue WHERE thread_id = ?`, [threadId]); }); const compactLegacyStorageImpl = Effect.fn("compactLegacyThreadStorage")(function* () { @@ -469,6 +509,8 @@ const make = Effect.gen(function* () { return { archiveThread: (threadId) => wrap("archive", threadId, archiveImpl(threadId)), restoreTree: (threadId) => wrap("restore", threadId, restoreTreeImpl(threadId)), + rollbackRestoreTree: (threadId) => + wrap("rollback-restore", threadId, rollbackRestoreTreeImpl(threadId)), finishRestoreTree: (threadId) => wrap("finish-restore", threadId, finishRestoreTreeImpl(threadId)), deleteThread: (threadId) => wrap("delete", threadId, deleteImpl(threadId)), @@ -478,7 +520,7 @@ const make = Effect.gen(function* () { Effect.mapError((cause) => storageError("compact-legacy-storage", "startup", cause)), ), listPendingArchiveThreadIds: listIds( - `SELECT thread_id FROM thread_archive_manifests WHERE status IN ('pending', 'archiving') ORDER BY archived_at ASC, thread_id ASC`, + `SELECT thread_id FROM thread_archive_manifests WHERE status IN ('pending', 'archiving', 'cleanup_pending') ORDER BY archived_at ASC, thread_id ASC`, "list-pending-archives", ), listPendingDeleteThreadIds: listIds( diff --git a/apps/server/src/orchestration/Services/ThreadColdStorage.ts b/apps/server/src/orchestration/Services/ThreadColdStorage.ts index 17380914ac6..acf8fe76f98 100644 --- a/apps/server/src/orchestration/Services/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Services/ThreadColdStorage.ts @@ -19,6 +19,7 @@ export class ThreadColdStorageError extends Schema.TaggedErrorClass Effect.Effect; readonly restoreTree: (threadId: ThreadId) => Effect.Effect; + readonly rollbackRestoreTree: (threadId: ThreadId) => Effect.Effect; readonly finishRestoreTree: (threadId: ThreadId) => Effect.Effect; readonly deleteThread: (threadId: ThreadId) => Effect.Effect; readonly removeProviderLogs: (threadId: ThreadId) => Effect.Effect; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index ed049106b45..0eca6109d06 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -123,6 +123,11 @@ export function useThreadActions() { ); if (navigationResult._tag === "Failure") { revealOptimisticallyArchivedThread(threadRef); + void archivePromise.then((archiveResult) => { + if (archiveResult._tag === "Success") { + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + } + }); return navigationResult; } } From 1b6c373a8a0784e64c0ba57504383fbe163290f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 14 Jul 2026 13:17:29 +0100 Subject: [PATCH 03/63] Harden cold archive restoration - Preserve binary SQL values across archive round trips - Keep cold bundles authoritative until attachments restore safely - Bound restore memory and tolerate compatible schema changes --- .../Layers/OrchestrationEngine.ts | 6 +- .../Layers/ThreadColdStorage.test.ts | 85 ++++++- .../orchestration/Layers/ThreadColdStorage.ts | 214 ++++++++++++++---- .../components/settings/SettingsPanels.tsx | 106 ++++----- 4 files changed, 311 insertions(+), 100 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 9e323e929ee..25f462ceada 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -250,11 +250,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); } } - if (unarchiveThreadId !== null && Option.isSome(threadColdStorage)) { - yield* threadColdStorage.value.finishRestoreTree(unarchiveThreadId).pipe( + if (restoredUnarchiveThreadId !== null && Option.isSome(threadColdStorage)) { + yield* threadColdStorage.value.finishRestoreTree(restoredUnarchiveThreadId).pipe( Effect.catchCause((cause) => Effect.logWarning("failed to finalize restored archive bundle", { - threadId: unarchiveThreadId, + threadId: restoredUnarchiveThreadId, cause: Cause.pretty(cause), }), ), diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 528ec182954..142f460d993 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -20,14 +20,12 @@ const insertArchivedThread = Effect.fn("insertArchivedThreadTestFixture")(functi yield* sql` INSERT INTO projection_threads ( thread_id, project_id, title, model_selection_json, runtime_mode, - interaction_mode, created_at, updated_at, archived_at, parent_kind, - root_thread_id + interaction_mode, created_at, updated_at, archived_at ) VALUES ( ${threadId}, 'project-1', ${title}, '{"instanceId":"codex","model":"gpt-5.5","options":[]}', 'full-access', 'default', '2026-07-01T00:00:00.000Z', - '2026-07-02T00:00:00.000Z', '2026-07-02T00:00:00.000Z', - 'root', ${threadId} + '2026-07-02T00:00:00.000Z', '2026-07-02T00:00:00.000Z' ) `; }); @@ -175,9 +173,10 @@ layer("ThreadColdStorage", (it) => { yield* insertArchivedThread(threadId, "Traversal thread"); yield* fs.writeFileString(path.join(config.attachmentsDir, attachmentName), "image bytes"); yield* storage.archiveThread(threadId); + const escapedPath = path.join(config.attachmentsDir, "..", "thread-traversal-escape"); yield* sql` UPDATE cold_archive.archive_thread_chunks - SET kind = 'attachment:..' + SET kind = 'attachment:../thread-traversal-escape' WHERE thread_id = ${threadId} AND kind LIKE 'attachment:%' `; @@ -186,6 +185,82 @@ layer("ThreadColdStorage", (it) => { SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} `; assert.deepStrictEqual(manifest, [{ status: "restored" }]); + assert.isFalse(yield* fs.exists(escapedPath)); + }), + ); + + it.effect("keeps cold SQL data authoritative when attachment restore fails", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-attachment-restore-failure"); + const attachmentName = + "thread-attachment-restore-failure-00000000-0000-4000-8000-000000000001.png"; + + yield* insertArchivedThread(threadId, "Attachment restore failure"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-attachment-restore-failure', ${threadId}, NULL, 'user', 'keep me cold', + '[]', 0, '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + yield* fs.writeFileString(path.join(config.attachmentsDir, attachmentName), "image bytes"); + yield* storage.archiveThread(threadId); + + const blockedTarget = path.join(config.attachmentsDir, "blocked-restore.bin"); + yield* fs.makeDirectory(blockedTarget); + yield* fs.writeFileString(path.join(blockedTarget, "keep"), "prevent replacement"); + yield* sql` + UPDATE cold_archive.archive_thread_chunks + SET kind = 'attachment:blocked-restore.bin' + WHERE thread_id = ${threadId} AND kind LIKE 'attachment:%' + `; + + const failure = yield* Effect.flip(storage.restoreTree(threadId)); + assert.strictEqual(failure.operation, "restore"); + const messages = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const manifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + const chunks = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM cold_archive.archive_thread_chunks WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(messages, [{ count: 0 }]); + assert.deepStrictEqual(manifest, [{ status: "cold" }]); + assert.isAbove(chunks[0]?.count ?? 0, 0); + }), + ); + + it.effect("round-trips binary SQL values", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-binary"); + const diffBytes = new Uint8Array([0, 1, 2, 127, 128, 255]); + + yield* insertArchivedThread(threadId, "Binary thread"); + yield* sql.unsafe( + `INSERT INTO checkpoint_diff_blobs + (thread_id, from_turn_count, to_turn_count, diff, created_at) + VALUES (?, 0, 1, ?, '2026-07-01T00:00:00.000Z')`, + [threadId, diffBytes], + ); + + yield* storage.archiveThread(threadId); + assert.isTrue(yield* storage.restoreTree(threadId)); + const restored = (yield* sql.unsafe( + `SELECT diff FROM checkpoint_diff_blobs WHERE thread_id = ?`, + [threadId], + )) as ReadonlyArray<{ readonly diff: Uint8Array }>; + assert.deepStrictEqual(restored[0]?.diff, diffBytes); }), ); diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index 0ee4b626bf9..be180ac11eb 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -9,10 +9,11 @@ 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 Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { parseAttachmentIdFromRelativePath } from "../../attachmentStore.ts"; import { + parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; @@ -28,6 +29,10 @@ const gunzipAsync = NodeUtil.promisify(NodeZlib.gunzip); const ARCHIVE_SCHEMA = "cold_archive"; const ARCHIVE_VERSION = 1; const ROW_CHUNK_SIZE = 250; +const RESTORE_CHUNK_PAGE_SIZE = 32; +const BINARY_VALUE_KEY = "__t3_archive_binary_base64"; +const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const decodeUnknownJsonString = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); type SqlRow = Record; @@ -52,12 +57,84 @@ function storageError(operation: string, threadId: string, cause: unknown) { return new ThreadColdStorageError({ operation, threadId, cause }); } -function encodeRows(rows: ReadonlyArray): Uint8Array { - return Buffer.from(JSON.stringify(rows), "utf8"); +function encodeRows(rows: ReadonlyArray): Effect.Effect { + return encodeUnknownJsonString( + rows.map((row) => + Object.fromEntries( + Object.entries(row).map(([column, value]) => [ + column, + value instanceof Uint8Array + ? { [BINARY_VALUE_KEY]: Buffer.from(value).toString("base64") } + : value, + ]), + ), + ), + ).pipe( + Effect.map((encoded) => Buffer.from(encoded, "utf8")), + Effect.mapError((cause) => new ArchiveCodecError({ cause })), + ); +} + +function decodeRows(data: Uint8Array): Effect.Effect, ArchiveCodecError> { + return decodeUnknownJsonString(Buffer.from(data).toString("utf8")).pipe( + Effect.flatMap((decoded) => + Effect.try({ + try: () => { + if (!Array.isArray(decoded)) { + throw new TypeError("Archived table chunk must contain an array of rows"); + } + return decoded.map((row): SqlRow => { + if (row === null || typeof row !== "object" || Array.isArray(row)) { + throw new TypeError("Archived table chunk contains an invalid row"); + } + return Object.fromEntries( + Object.entries(row).map(([column, value]) => { + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 1 && + typeof (value as Record)[BINARY_VALUE_KEY] === "string" + ) { + return [ + column, + new Uint8Array( + Buffer.from( + (value as Record)[BINARY_VALUE_KEY] as string, + "base64", + ), + ), + ]; + } + return [column, value]; + }), + ); + }); + }, + catch: (cause) => new ArchiveCodecError({ cause }), + }), + ), + Effect.mapError((cause) => + cause instanceof ArchiveCodecError ? cause : new ArchiveCodecError({ cause }), + ), + ); } -function decodeRows(data: Uint8Array): ReadonlyArray { - return JSON.parse(Buffer.from(data).toString("utf8")) as ReadonlyArray; +const THREAD_TABLE_NAMES = new Set(THREAD_TABLES.map(([table]) => table)); + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function isSafeAttachmentEntry(entry: string): boolean { + return ( + entry.length > 0 && + entry !== "." && + entry !== ".." && + !entry.includes("/") && + !entry.includes("\\") && + !entry.includes("\0") + ); } const compress = (data: Uint8Array) => @@ -137,10 +214,15 @@ const make = Effect.gen(function* () { .readDirectory(config.providerLogsDir, { recursive: false }) .pipe(Effect.orElseSucceed(() => [] as string[])); yield* Effect.forEach( - entries.filter( - (entry) => - entry === baseName || new RegExp(`^${baseName.replace(".", "\\.")}\\.\\d+$`).test(entry), - ), + entries.filter((entry) => { + if (entry === baseName) return true; + if (!entry.startsWith(`${baseName}.`)) return false; + const rotation = entry.slice(baseName.length + 1); + return ( + rotation.length > 0 && + [...rotation].every((character) => character >= "0" && character <= "9") + ); + }), (entry) => fs.remove(path.join(config.providerLogsDir, entry), { force: true }), { concurrency: 4, discard: true }, ); @@ -243,7 +325,7 @@ const make = Effect.gen(function* () { lastRowId = Number(__archive_rowid); return stored; }); - const encoded = encodeRows(normalizedRows); + const encoded = yield* encodeRows(normalizedRows); const compressed = yield* compress(encoded); yield* insertChunk({ threadId, @@ -274,6 +356,8 @@ const make = Effect.gen(function* () { compressedBytes += compressed.byteLength; } + // Chunk creation stays retryable outside the hot-row deletion transaction. + // A retry replaces every partial chunk before deleting source data. yield* sql.withTransaction( Effect.gen(function* () { yield* sql.unsafe( @@ -303,34 +387,102 @@ const make = Effect.gen(function* () { table: string, rows: ReadonlyArray, ) { + if (!THREAD_TABLE_NAMES.has(table)) { + return yield* new ArchiveCodecError({ + cause: new TypeError(`Archive chunk targets unknown table '${table}'`), + }); + } + const tableInfo = (yield* sql.unsafe( + `PRAGMA main.table_info(${quoteIdentifier(table)})`, + )) as ReadonlyArray; + const currentColumns = new Set( + tableInfo.flatMap((column) => + typeof column.name === "string" && column.name.length > 0 ? [column.name] : [], + ), + ); for (const row of rows) { - const columns = Object.keys(row); + // Archived rows can outlive schema migrations. Ignore columns that no + // longer exist and let newly-added columns use their database defaults. + const columns = Object.keys(row).filter((column) => currentColumns.has(column)); if (columns.length === 0) continue; const placeholders = columns.map(() => "?").join(", "); yield* sql.unsafe( - `INSERT OR REPLACE INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`, + `INSERT OR REPLACE INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${placeholders})`, columns.map((column) => row[column]), ); } }); const restoreThread = Effect.fn("restoreArchivedThread")(function* (threadId: ThreadId) { - const chunks = (yield* sql.unsafe( - `SELECT chunk_index, kind, data - FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks - WHERE thread_id = ? - ORDER BY chunk_index ASC`, + const bundleRows = (yield* sql.unsafe( + `SELECT 1 AS present FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ? LIMIT 1`, [threadId], )) as ReadonlyArray; - if (chunks.length === 0) return false; + if (bundleRows.length === 0) return false; + + const invalidKinds = (yield* sql.unsafe( + `SELECT kind FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks + WHERE thread_id = ? AND kind NOT LIKE 'table:%' AND kind NOT LIKE 'attachment:%' + LIMIT 1`, + [threadId], + )) as ReadonlyArray; + if (invalidKinds.length > 0) { + return yield* new ArchiveCodecError({ + cause: new TypeError( + `Archive contains unknown chunk kind '${String(invalidKinds[0]?.kind)}'`, + ), + }); + } + + // Restore files before changing SQL state. A failed or interrupted file + // write leaves the cold bundle authoritative and can be retried safely. + let attachmentChunkIndex = -1; + while (true) { + const attachmentChunks = (yield* sql.unsafe( + `SELECT chunk_index, kind, data + FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks + WHERE thread_id = ? AND chunk_index > ? AND kind LIKE 'attachment:%' + ORDER BY chunk_index ASC + LIMIT ${RESTORE_CHUNK_PAGE_SIZE}`, + [threadId, attachmentChunkIndex], + )) as ReadonlyArray; + if (attachmentChunks.length === 0) break; + for (const chunk of attachmentChunks) { + attachmentChunkIndex = Number(chunk.chunk_index); + const entry = String(chunk.kind).slice("attachment:".length); + if (!isSafeAttachmentEntry(entry)) continue; + const data = yield* decompress(chunk.data as Uint8Array); + const targetPath = path.join(config.attachmentsDir, entry); + const temporaryPath = `${targetPath}.t3-restore`; + yield* fs + .writeFile(temporaryPath, data) + .pipe( + Effect.andThen(fs.remove(targetPath, { force: true })), + Effect.andThen(fs.rename(temporaryPath, targetPath)), + Effect.ensuring(fs.remove(temporaryPath, { force: true }).pipe(Effect.ignore)), + ); + } + } yield* sql.withTransaction( Effect.gen(function* () { - for (const chunk of chunks) { - const kind = String(chunk.kind); - const data = yield* decompress(chunk.data as Uint8Array); - if (!kind.startsWith("table:")) continue; - yield* insertRows(kind.slice("table:".length), decodeRows(data)); + let tableChunkIndex = -1; + while (true) { + const tableChunks = (yield* sql.unsafe( + `SELECT chunk_index, kind, data + FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks + WHERE thread_id = ? AND chunk_index > ? AND kind LIKE 'table:%' + ORDER BY chunk_index ASC + LIMIT ${RESTORE_CHUNK_PAGE_SIZE}`, + [threadId, tableChunkIndex], + )) as ReadonlyArray; + if (tableChunks.length === 0) break; + for (const chunk of tableChunks) { + tableChunkIndex = Number(chunk.chunk_index); + const kind = String(chunk.kind); + const data = yield* decompress(chunk.data as Uint8Array); + yield* insertRows(kind.slice("table:".length), yield* decodeRows(data)); + } } yield* sql.unsafe( `UPDATE thread_archive_manifests @@ -341,22 +493,6 @@ const make = Effect.gen(function* () { }), ); - for (const chunk of chunks) { - const kind = String(chunk.kind); - if (!kind.startsWith("attachment:")) continue; - const entry = kind.slice("attachment:".length); - if ( - entry.length === 0 || - entry === "." || - entry === ".." || - entry.includes("/") || - entry.includes("\\") - ) { - continue; - } - const data = yield* decompress(chunk.data as Uint8Array); - yield* fs.writeFile(path.join(config.attachmentsDir, entry), data); - } return true; }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ab258959a73..95ec76b694a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1591,60 +1591,60 @@ export function ArchivedThreadsPanel() { return ( { - event.preventDefault(); - if (isUnarchiving) return; - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived thread action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), + onContextMenu={(event) => { + event.preventDefault(); + if (isUnarchiving) return; + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ), ); - } - })(); - }} - title={thread.title} - description={ - <> - Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} - {" \u00b7 Created "} - {formatRelativeTimeLabel(thread.createdAt)} - - } - control={ - - } - /> + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived thread action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }} + title={thread.title} + description={ + <> + Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} + {" \u00b7 Created "} + {formatRelativeTimeLabel(thread.createdAt)} + + } + control={ + + } + /> ); })} From 71f28adc812f366bd339dfec94c3d649bbb8d3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 15 Jul 2026 21:54:57 +0100 Subject: [PATCH 04/63] Harden cold storage lifecycle races - Serialize archive-tree lifecycle and recheck archived shells - Restore cleanup-pending bundles before unarchive commits - Preserve retry state for writer and filesystem failures --- .../Layers/OrchestrationEngine.ts | 28 ++-- .../Layers/ThreadColdStorage.test.ts | 140 ++++++++++++++++++ .../orchestration/Layers/ThreadColdStorage.ts | 124 ++++++++++++++-- .../Layers/ThreadDeletionReactor.ts | 31 ++-- 4 files changed, 291 insertions(+), 32 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 25f462ceada..6e1defce881 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -108,6 +108,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const dispatchStartSequence = commandReadModel.snapshotSequence; let processingStartedAtMs = 0; let restoredUnarchiveThreadId: ThreadId | null = null; + let restoredUnarchiveCommitted = false; const aggregateRef = commandToAggregateRef(envelope.command); const baseMetricAttributes = { commandType: envelope.command.type, @@ -234,7 +235,18 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); + restoredUnarchiveCommitted = restoredUnarchiveThreadId !== null; commandReadModel = committedCommand.nextCommandReadModel; + if (restoredUnarchiveThreadId !== null && Option.isSome(threadColdStorage)) { + yield* threadColdStorage.value.finishRestoreTree(restoredUnarchiveThreadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to finalize restored archive bundle", { + threadId: restoredUnarchiveThreadId, + cause: Cause.pretty(cause), + }), + ), + ); + } for (const [index, event] of committedCommand.committedEvents.entries()) { yield* PubSub.publish(eventPubSub, event); if (index === 0) { @@ -250,16 +262,6 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); } } - if (restoredUnarchiveThreadId !== null && Option.isSome(threadColdStorage)) { - yield* threadColdStorage.value.finishRestoreTree(restoredUnarchiveThreadId).pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to finalize restored archive bundle", { - threadId: restoredUnarchiveThreadId, - cause: Cause.pretty(cause), - }), - ), - ); - } return { sequence: committedCommand.lastSequence }; }).pipe(Effect.withSpan(`orchestration.command.${envelope.command.type}`)), ).pipe( @@ -293,7 +295,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { return; } - if (restoredUnarchiveThreadId !== null && Option.isSome(threadColdStorage)) { + if ( + restoredUnarchiveThreadId !== null && + !restoredUnarchiveCommitted && + Option.isSome(threadColdStorage) + ) { const rolledBack = yield* threadColdStorage.value .rollbackRestoreTree(restoredUnarchiveThreadId) .pipe( diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 142f460d993..75d556e8909 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -39,6 +39,17 @@ const layer = it.layer( ); layer("ThreadColdStorage", (it) => { + it.effect("discovers archived shells before a lifecycle manifest exists", () => + Effect.gen(function* () { + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-archive-queue-fallback"); + + yield* insertArchivedThread(threadId, "Archive queue fallback thread"); + + assert.deepInclude(yield* storage.listPendingArchiveThreadIds, threadId); + }), + ); + it.effect("compresses conversation data, destroys logs, restores content, and hard-deletes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -121,6 +132,18 @@ layer("ThreadColdStorage", (it) => { assert.strictEqual(yield* fs.readFileString(attachmentPath), "image bytes"); assert.isFalse(yield* fs.exists(providerLogPath)); + // A queued archive job can run after restore but before the unarchive + // command commits. It must not undo a restore owned by that command. + yield* storage.archiveThread(threadId); + const stillRestoredMessages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const stillRestoredManifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(stillRestoredMessages, [{ text: "keep this conversation" }]); + assert.deepStrictEqual(stillRestoredManifest, [{ status: "restored" }]); + yield* storage.rollbackRestoreTree(threadId); const rolledBackMessages = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} @@ -310,6 +333,123 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("restores cleanup-pending bundles before unarchiving", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-cleanup-pending-restore"); + + yield* insertArchivedThread(threadId, "Cleanup-pending restore thread"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-cleanup-pending-restore', ${threadId}, NULL, 'user', + 'restore while cleanup is pending', '[]', 0, + '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + + yield* storage.archiveThread(threadId); + yield* sql` + UPDATE thread_archive_manifests + SET status = 'cleanup_pending' + WHERE thread_id = ${threadId} + `; + + assert.isTrue(yield* storage.restoreTree(threadId)); + const restoredMessages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const manifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(restoredMessages, [{ text: "restore while cleanup is pending" }]); + assert.deepStrictEqual(manifest, [{ status: "restored" }]); + }), + ); + + it.effect("abandons an incomplete archive after the shell is unarchived", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-unarchived-before-archive"); + + yield* insertArchivedThread(threadId, "Unarchived before archive thread"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-unarchived-before-archive', ${threadId}, NULL, 'user', + 'keep active data hot', '[]', 0, + '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO thread_archive_manifests ( + thread_id, root_thread_id, status, archive_version, archived_at, updated_at + ) VALUES ( + ${threadId}, ${threadId}, 'pending', 1, + '2026-07-02T00:00:00.000Z', CURRENT_TIMESTAMP + ) + `; + yield* sql` + UPDATE projection_threads SET archived_at = NULL WHERE thread_id = ${threadId} + `; + + yield* storage.archiveThread(threadId); + const messages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const manifests = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + const chunks = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM cold_archive.archive_thread_chunks + WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(messages, [{ text: "keep active data hot" }]); + assert.deepStrictEqual(manifests, [{ count: 0 }]); + assert.deepStrictEqual(chunks, [{ count: 0 }]); + }), + ); + + it.effect("retries attachment cleanup after directory I/O errors", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const threadId = ThreadId.make("thread-attachment-directory-error"); + + yield* insertArchivedThread(threadId, "Attachment directory error thread"); + yield* fs.remove(config.attachmentsDir, { recursive: true }); + yield* fs.writeFileString(config.attachmentsDir, "not a directory"); + + const archiveFailure = yield* Effect.flip(storage.archiveThread(threadId)); + assert.strictEqual(archiveFailure.operation, "archive"); + const shell = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_threads WHERE thread_id = ${threadId} + `; + const manifests = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(shell, [{ count: 1 }]); + assert.deepStrictEqual(manifests, [{ status: "archiving" }]); + + yield* fs.remove(config.attachmentsDir); + yield* fs.makeDirectory(config.attachmentsDir); + yield* storage.archiveThread(threadId); + const completedManifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(completedManifest, [{ status: "cold" }]); + }), + ); + it.effect("keeps the delete cleanup queue entry until external cleanup succeeds", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index be180ac11eb..e430707010e 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -8,8 +8,11 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; 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 Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { @@ -154,6 +157,7 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; + const threadLocksRef = yield* SynchronizedRef.make(new Map()); yield* sql.unsafe(`ATTACH DATABASE ? AS ${ARCHIVE_SCHEMA}`, [config.archiveDbPath]); yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.auto_vacuum = INCREMENTAL`); @@ -190,7 +194,11 @@ const make = Effect.gen(function* () { if (!segment) return [] as string[]; const entries = yield* fs .readDirectory(config.attachmentsDir, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as string[])); + .pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed([] as string[]) : Effect.fail(error), + ), + ); return entries.filter((entry) => { const attachmentId = parseAttachmentIdFromRelativePath(entry); return attachmentId !== null && parseThreadSegmentFromAttachmentId(attachmentId) === segment; @@ -212,7 +220,11 @@ const make = Effect.gen(function* () { const baseName = `${segment}.log`; const entries = yield* fs .readDirectory(config.providerLogsDir, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as string[])); + .pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed([] as string[]) : Effect.fail(error), + ), + ); yield* Effect.forEach( entries.filter((entry) => { if (entry === baseName) return true; @@ -262,7 +274,31 @@ const make = Effect.gen(function* () { ); }); - const archiveImpl = Effect.fn("archiveThreadImpl")(function* (threadId: ThreadId) { + const discardIncompleteArchive = Effect.fn("discardIncompleteThreadArchive")(function* ( + threadId: ThreadId, + ) { + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql.unsafe( + `DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, + [threadId], + ); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ + threadId, + ]); + yield* sql.unsafe( + `DELETE FROM thread_archive_manifests + WHERE thread_id = ? AND status IN ('pending', 'archiving')`, + [threadId], + ); + }), + ); + }); + + const archiveImpl = Effect.fn("archiveThreadImpl")(function* ( + threadId: ThreadId, + allowRestored: boolean, + ) { const manifestRows = (yield* sql.unsafe( `SELECT root_thread_id, archived_at, status FROM thread_archive_manifests @@ -275,13 +311,21 @@ const make = Effect.gen(function* () { WHERE thread_id = ? AND deleted_at IS NULL AND archived_at IS NOT NULL`, [threadId], )) as ReadonlyArray; - const source = manifestRows[0] ?? threadRows[0]; + const manifest = manifestRows[0]; + const source = manifest ?? threadRows[0]; if (!source) return; + if (threadRows.length === 0) { + if (source.status === "pending" || source.status === "archiving") { + yield* discardIncompleteArchive(threadId); + } + return; + } if (source.status === "cold") return; if (source.status === "cleanup_pending") { yield* completeArchiveCleanup(threadId); return; } + if (source.status === "restored" && !allowRestored) return; const rootThreadId = String(source.root_thread_id ?? threadId); const archivedAt = String(source.archived_at ?? DateTime.formatIso(yield* DateTime.now)); @@ -358,8 +402,30 @@ const make = Effect.gen(function* () { // Chunk creation stays retryable outside the hot-row deletion transaction. // A retry replaces every partial chunk before deleting source data. - yield* sql.withTransaction( + const archivedAtDestructiveBoundary = yield* sql.withTransaction( Effect.gen(function* () { + const archivedShell = (yield* sql.unsafe( + `SELECT 1 AS present + FROM projection_threads + WHERE thread_id = ? AND deleted_at IS NULL AND archived_at IS NOT NULL + LIMIT 1`, + [threadId], + )) as ReadonlyArray; + if (archivedShell.length === 0) { + yield* sql.unsafe( + `DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, + [threadId], + ); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ + threadId, + ]); + yield* sql.unsafe( + `DELETE FROM thread_archive_manifests + WHERE thread_id = ? AND status = 'archiving'`, + [threadId], + ); + return false; + } yield* sql.unsafe( `INSERT INTO ${ARCHIVE_SCHEMA}.archive_threads (thread_id, root_thread_id, archive_version, archived_at, original_bytes, @@ -377,9 +443,12 @@ const make = Effect.gen(function* () { WHERE thread_id = ?`, [originalBytes, compressedBytes, threadId], ); + return true; }), ); + if (!archivedAtDestructiveBoundary) return; + yield* completeArchiveCleanup(threadId); }); @@ -513,7 +582,7 @@ const make = Effect.gen(function* () { const rows = (yield* sql.unsafe( `SELECT thread_id FROM thread_archive_manifests - WHERE root_thread_id = ? AND status IN ('cold', 'restored') + WHERE root_thread_id = ? AND status IN ('cleanup_pending', 'cold', 'restored') ORDER BY CASE WHEN thread_id = ? THEN 1 ELSE 0 END, thread_id ASC`, [rootThreadId, rootThreadId], )) as ReadonlyArray; @@ -536,7 +605,7 @@ const make = Effect.gen(function* () { [rootThreadId, rootThreadId], )) as ReadonlyArray; for (const row of rows) { - yield* archiveImpl(ThreadId.make(String(row.thread_id))); + yield* archiveImpl(ThreadId.make(String(row.thread_id)), true); } }); @@ -631,8 +700,28 @@ const make = Effect.gen(function* () { ); }); + const getTreeSemaphore = (threadId: ThreadId) => + Effect.flatMap(resolveTreeRoot(threadId), (rootThreadId) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = Option.fromNullishOr(current.get(rootThreadId)); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(rootThreadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }), + ); + const wrap = (operation: string, threadId: ThreadId, effect: Effect.Effect) => - effect.pipe(Effect.mapError((cause) => storageError(operation, threadId, cause))); + Effect.flatMap(getTreeSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)).pipe( + Effect.mapError((cause) => storageError(operation, threadId, cause)), + ); const listIds = (query: string, operation: string) => sql.unsafe(query).pipe( @@ -643,7 +732,7 @@ const make = Effect.gen(function* () { ); return { - archiveThread: (threadId) => wrap("archive", threadId, archiveImpl(threadId)), + archiveThread: (threadId) => wrap("archive", threadId, archiveImpl(threadId, false)), restoreTree: (threadId) => wrap("restore", threadId, restoreTreeImpl(threadId)), rollbackRestoreTree: (threadId) => wrap("rollback-restore", threadId, rollbackRestoreTreeImpl(threadId)), @@ -656,7 +745,22 @@ const make = Effect.gen(function* () { Effect.mapError((cause) => storageError("compact-legacy-storage", "startup", cause)), ), listPendingArchiveThreadIds: listIds( - `SELECT thread_id FROM thread_archive_manifests WHERE status IN ('pending', 'archiving', 'cleanup_pending') ORDER BY archived_at ASC, thread_id ASC`, + `SELECT thread_id + FROM ( + SELECT thread_id, archived_at + FROM thread_archive_manifests + WHERE status IN ('pending', 'archiving', 'cleanup_pending') + UNION ALL + SELECT projection_threads.thread_id, projection_threads.archived_at + FROM projection_threads + WHERE projection_threads.archived_at IS NOT NULL + AND projection_threads.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM thread_archive_manifests + WHERE thread_archive_manifests.thread_id = projection_threads.thread_id + ) + ) + ORDER BY archived_at ASC, thread_id ASC`, "list-pending-archives", ), listPendingDeleteThreadIds: listIds( diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 4023dba8d8d..7a3a907bdaf 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -64,14 +64,17 @@ const make = Effect.gen(function* () { threadId, }); + const closeProviderLogWritersRequired = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + Effect.all( + [providerEventLoggers.native, providerEventLoggers.canonical].flatMap((logger) => + logger?.closeThread ? [logger.closeThread(threadId)] : [], + ), + { discard: true }, + ); + const closeProviderLogWriters = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ - effect: Effect.all( - [providerEventLoggers.native, providerEventLoggers.canonical].flatMap((logger) => - logger?.closeThread ? [logger.closeThread(threadId)] : [], - ), - { discard: true }, - ), + effect: closeProviderLogWritersRequired(threadId), message: "thread lifecycle cleanup skipped provider log writer close", threadId, }); @@ -84,14 +87,20 @@ const make = Effect.gen(function* () { return; } const { threadId } = job; - yield* stopProviderSession(threadId); - yield* closeThreadTerminals(threadId); - yield* closeProviderLogWriters(threadId); if (job.type === "archive") { + // Archiving must not snapshot or delete hot rows while any active writer + // can still mutate them. A failure leaves the durable archived shell or + // manifest discoverable so startup recovery can retry the boundary. + yield* providerService.stopSession({ threadId }); + yield* terminalManager.close({ threadId, deleteHistory: true }); + yield* closeProviderLogWritersRequired(threadId); yield* threadColdStorage.archiveThread(threadId); - } else { - yield* threadColdStorage.deleteThread(threadId); + return; } + yield* stopProviderSession(threadId); + yield* closeThreadTerminals(threadId); + yield* closeProviderLogWriters(threadId); + yield* threadColdStorage.deleteThread(threadId); }); const processLifecycleJobSafely = (job: ThreadLifecycleJob) => From 6ac4c9d9139d57b126d225ee7b9378e510159770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 15 Jul 2026 21:59:45 +0100 Subject: [PATCH 05/63] Evict idle cold storage lifecycle locks - Reference-count archive-tree lock users and waiters - Remove lock entries after the final operation releases them --- .../orchestration/Layers/ThreadColdStorage.ts | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index e430707010e..73803902673 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -8,7 +8,6 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; 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 Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -38,6 +37,14 @@ const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJso const decodeUnknownJsonString = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); type SqlRow = Record; +type ThreadLockEntry = { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; +}; +type AcquiredThreadLock = { + readonly rootThreadId: ThreadId; + readonly semaphore: Semaphore.Semaphore; +}; class ArchiveCodecError extends Data.TaggedError("ArchiveCodecError")<{ readonly cause: unknown; @@ -157,7 +164,7 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; - const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); yield* sql.unsafe(`ATTACH DATABASE ? AS ${ARCHIVE_SCHEMA}`, [config.archiveDbPath]); yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.auto_vacuum = INCREMENTAL`); @@ -703,25 +710,44 @@ const make = Effect.gen(function* () { const getTreeSemaphore = (threadId: ThreadId) => Effect.flatMap(resolveTreeRoot(threadId), (rootThreadId) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing = Option.fromNullishOr(current.get(rootThreadId)); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(rootThreadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); + const existing = current.get(rootThreadId); + if (existing) { + const next = new Map(current); + next.set(rootThreadId, { ...existing, users: existing.users + 1 }); + return Effect.succeed([ + { rootThreadId, semaphore: existing.semaphore } satisfies AcquiredThreadLock, + next, + ] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(rootThreadId, { semaphore, users: 1 }); + return [{ rootThreadId, semaphore } satisfies AcquiredThreadLock, next] as const; + }), + ); }), ); + const releaseTreeSemaphore = (acquired: AcquiredThreadLock) => + SynchronizedRef.update(threadLocksRef, (current) => { + const existing = current.get(acquired.rootThreadId); + if (!existing || existing.semaphore !== acquired.semaphore) return current; + const next = new Map(current); + if (existing.users === 1) { + next.delete(acquired.rootThreadId); + } else { + next.set(acquired.rootThreadId, { ...existing, users: existing.users - 1 }); + } + return next; + }); + const wrap = (operation: string, threadId: ThreadId, effect: Effect.Effect) => - Effect.flatMap(getTreeSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)).pipe( - Effect.mapError((cause) => storageError(operation, threadId, cause)), - ); + Effect.acquireUseRelease( + getTreeSemaphore(threadId), + ({ semaphore }) => semaphore.withPermit(effect), + releaseTreeSemaphore, + ).pipe(Effect.mapError((cause) => storageError(operation, threadId, cause))); const listIds = (query: string, operation: string) => sql.unsafe(query).pipe( From 9e5cad6cbc1068dfa6bd11db95a526afa0c451db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 15 Jul 2026 22:02:19 +0100 Subject: [PATCH 06/63] Infer cold storage service contract - Define the service members inline with Context.Service - Use the inferred Service type in the layer and orchestration test --- .../Layers/OrchestrationEngine.test.ts | 4 +- .../orchestration/Layers/ThreadColdStorage.ts | 8 +--- .../Services/ThreadColdStorage.ts | 45 ++++++++++--------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 45c4bdd9dbb..d424c17859e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -39,7 +39,7 @@ import { } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; -import { ThreadColdStorage, type ThreadColdStorageShape } from "../Services/ThreadColdStorage.ts"; +import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); @@ -899,7 +899,7 @@ describe("OrchestrationEngine", () => { return Stream.fromIterable(events); }, }; - const coldStorage: ThreadColdStorageShape = { + const coldStorage: ThreadColdStorage["Service"] = { archiveThread: () => Effect.void, restoreTree: () => Effect.succeed(restoreOnUnarchive), rollbackRestoreTree: (threadId) => diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index 73803902673..63d3b3a4c93 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -20,11 +20,7 @@ import { toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { - ThreadColdStorage, - ThreadColdStorageError, - type ThreadColdStorageShape, -} from "../Services/ThreadColdStorage.ts"; +import { ThreadColdStorage, ThreadColdStorageError } from "../Services/ThreadColdStorage.ts"; const gzipAsync = NodeUtil.promisify(NodeZlib.gzip); const gunzipAsync = NodeUtil.promisify(NodeZlib.gunzip); @@ -793,7 +789,7 @@ const make = Effect.gen(function* () { `SELECT thread_id FROM thread_cleanup_queue WHERE reason = 'deleted' ORDER BY created_at ASC, thread_id ASC`, "list-pending-deletes", ), - } satisfies ThreadColdStorageShape; + } satisfies ThreadColdStorage["Service"]; }); export const ThreadColdStorageLive = Layer.effect(ThreadColdStorage, make); diff --git a/apps/server/src/orchestration/Services/ThreadColdStorage.ts b/apps/server/src/orchestration/Services/ThreadColdStorage.ts index acf8fe76f98..3c4b7b3f765 100644 --- a/apps/server/src/orchestration/Services/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Services/ThreadColdStorage.ts @@ -16,24 +16,27 @@ export class ThreadColdStorageError extends Schema.TaggedErrorClass Effect.Effect; - readonly restoreTree: (threadId: ThreadId) => Effect.Effect; - readonly rollbackRestoreTree: (threadId: ThreadId) => Effect.Effect; - readonly finishRestoreTree: (threadId: ThreadId) => Effect.Effect; - readonly deleteThread: (threadId: ThreadId) => Effect.Effect; - readonly removeProviderLogs: (threadId: ThreadId) => Effect.Effect; - readonly compactLegacyStorage: Effect.Effect; - readonly listPendingArchiveThreadIds: Effect.Effect< - ReadonlyArray, - ThreadColdStorageError - >; - readonly listPendingDeleteThreadIds: Effect.Effect< - ReadonlyArray, - ThreadColdStorageError - >; -} - -export class ThreadColdStorage extends Context.Service()( - "t3/orchestration/Services/ThreadColdStorage", -) {} +export class ThreadColdStorage extends Context.Service< + ThreadColdStorage, + { + readonly archiveThread: (threadId: ThreadId) => Effect.Effect; + readonly restoreTree: (threadId: ThreadId) => Effect.Effect; + readonly rollbackRestoreTree: ( + threadId: ThreadId, + ) => Effect.Effect; + readonly finishRestoreTree: (threadId: ThreadId) => Effect.Effect; + readonly deleteThread: (threadId: ThreadId) => Effect.Effect; + readonly removeProviderLogs: ( + threadId: ThreadId, + ) => Effect.Effect; + readonly compactLegacyStorage: Effect.Effect; + readonly listPendingArchiveThreadIds: Effect.Effect< + ReadonlyArray, + ThreadColdStorageError + >; + readonly listPendingDeleteThreadIds: Effect.Effect< + ReadonlyArray, + ThreadColdStorageError + >; + } +>()("t3/orchestration/Services/ThreadColdStorage") {} From a42fe41cb825f5fc516fe721d21c22e60213235c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 15 Jul 2026 23:17:19 +0100 Subject: [PATCH 07/63] Fix cold storage attachment ownership - Match archived attachments by exact persisted ids - Resume cleanup pending manifests without shell rows - Preserve attachment metadata until durable delete cleanup succeeds --- .../Layers/ThreadColdStorage.test.ts | 152 +++++++++++++++++- .../orchestration/Layers/ThreadColdStorage.ts | 56 +++++-- 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 75d556e8909..7d088b679f8 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -5,6 +5,7 @@ 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 Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../../config.ts"; @@ -12,6 +13,8 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; +const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + const insertArchivedThread = Effect.fn("insertArchivedThreadTestFixture")(function* ( threadId: ThreadId, title: string, @@ -194,6 +197,26 @@ layer("ThreadColdStorage", (it) => { const attachmentName = "thread-traversal-00000000-0000-4000-8000-000000000001.png"; yield* insertArchivedThread(threadId, "Traversal thread"); + yield* sql.unsafe( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES (?, ?, NULL, 'user', 'validate attachment name', ?, 0, ?, ?)`, + [ + "message-traversal", + threadId, + encodeUnknownJsonString([ + { + type: "image", + id: attachmentName.slice(0, -4), + name: "image.png", + mimeType: "image/png", + }, + ]), + "2026-07-01T00:00:00.000Z", + "2026-07-01T00:00:00.000Z", + ], + ); yield* fs.writeFileString(path.join(config.attachmentsDir, attachmentName), "image bytes"); yield* storage.archiveThread(threadId); const escapedPath = path.join(config.attachmentsDir, "..", "thread-traversal-escape"); @@ -230,7 +253,8 @@ layer("ThreadColdStorage", (it) => { is_streaming, created_at, updated_at ) VALUES ( 'message-attachment-restore-failure', ${threadId}, NULL, 'user', 'keep me cold', - '[]', 0, '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + '[{"type":"image","id":"thread-attachment-restore-failure-00000000-0000-4000-8000-000000000001","name":"image.png","mimeType":"image/png"}]', + 0, '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' ) `; yield* fs.writeFileString(path.join(config.attachmentsDir, attachmentName), "image bytes"); @@ -333,6 +357,112 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("finishes cleanup-pending archives after their shell is removed", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-cleanup-missing-shell"); + const providerLogPath = path.join(config.providerLogsDir, "thread-cleanup-missing-shell.log"); + + yield* insertArchivedThread(threadId, "Cleanup missing shell thread"); + yield* fs.makeDirectory(providerLogPath); + yield* fs.writeFileString(path.join(providerLogPath, "keep"), "force cleanup failure"); + + const archiveFailure = yield* Effect.flip(storage.archiveThread(threadId)); + assert.strictEqual(archiveFailure.operation, "archive"); + yield* sql`DELETE FROM projection_threads WHERE thread_id = ${threadId}`; + yield* fs.remove(providerLogPath, { recursive: true }); + + yield* storage.archiveThread(threadId); + const manifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(manifest, [{ status: "cold" }]); + assert.notDeepInclude(yield* storage.listPendingArchiveThreadIds, threadId); + }), + ); + + it.effect("archives only attachments owned by colliding thread segments", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const archivedThreadId = ThreadId.make("Thread.Foo"); + const liveThreadId = ThreadId.make("thread foo"); + const archivedAttachmentId = "thread-foo-00000000-0000-4000-8000-000000000001"; + const liveAttachmentId = "thread-foo-00000000-0000-4000-8000-000000000002"; + const archivedAttachmentName = `${archivedAttachmentId}.png`; + const liveAttachmentName = `${liveAttachmentId}.png`; + + yield* insertArchivedThread(archivedThreadId, "Archived colliding thread"); + yield* insertArchivedThread(liveThreadId, "Live colliding thread"); + yield* sql` + UPDATE projection_threads SET archived_at = NULL WHERE thread_id = ${liveThreadId} + `; + yield* sql.unsafe( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES (?, ?, NULL, 'user', ?, ?, 0, ?, ?)`, + [ + "message-archived-collision", + archivedThreadId, + "archive only my attachment", + encodeUnknownJsonString([ + { + type: "image", + id: archivedAttachmentId, + name: "archived.png", + mimeType: "image/png", + }, + ]), + "2026-07-01T00:00:00.000Z", + "2026-07-01T00:00:00.000Z", + ], + ); + yield* sql.unsafe( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES (?, ?, NULL, 'user', ?, ?, 0, ?, ?)`, + [ + "message-live-collision", + liveThreadId, + "keep my attachment live", + encodeUnknownJsonString([ + { + type: "image", + id: liveAttachmentId, + name: "live.png", + mimeType: "image/png", + }, + ]), + "2026-07-01T00:00:00.000Z", + "2026-07-01T00:00:00.000Z", + ], + ); + const archivedAttachmentPath = path.join(config.attachmentsDir, archivedAttachmentName); + const liveAttachmentPath = path.join(config.attachmentsDir, liveAttachmentName); + yield* fs.writeFileString(archivedAttachmentPath, "archived image"); + yield* fs.writeFileString(liveAttachmentPath, "live image"); + + yield* storage.archiveThread(archivedThreadId); + + assert.isFalse(yield* fs.exists(archivedAttachmentPath)); + assert.strictEqual(yield* fs.readFileString(liveAttachmentPath), "live image"); + const archivedChunks = yield* sql<{ readonly kind: string }>` + SELECT kind FROM cold_archive.archive_thread_chunks + WHERE thread_id = ${archivedThreadId} AND kind LIKE 'attachment:%' + `; + assert.deepStrictEqual(archivedChunks, [{ kind: `attachment:${archivedAttachmentName}` }]); + }), + ); + it.effect("restores cleanup-pending bundles before unarchiving", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -462,6 +592,26 @@ layer("ThreadColdStorage", (it) => { const attachmentPath = path.join(config.attachmentsDir, attachmentName); yield* insertArchivedThread(threadId, "Delete retry thread"); + yield* sql.unsafe( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES (?, ?, NULL, 'user', 'delete me', ?, 0, ?, ?)`, + [ + "message-delete-retry", + threadId, + encodeUnknownJsonString([ + { + type: "image", + id: attachmentName.slice(0, -4), + name: "delete.png", + mimeType: "image/png", + }, + ]), + "2026-07-01T00:00:00.000Z", + "2026-07-01T00:00:00.000Z", + ], + ); yield* fs.makeDirectory(attachmentPath); yield* fs.writeFileString(path.join(attachmentPath, "keep"), "force cleanup failure"); diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index 63d3b3a4c93..bfc2b0272ed 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -16,7 +16,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { parseAttachmentIdFromRelativePath, - parseThreadSegmentFromAttachmentId, toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -193,8 +192,40 @@ const make = Effect.gen(function* () { const attachmentEntriesForThread = Effect.fn("attachmentEntriesForThread")(function* ( threadId: string, ) { - const segment = toSafeThreadAttachmentSegment(threadId); - if (!segment) return [] as string[]; + const attachmentIds = new Set(); + const attachmentRows = (yield* sql.unsafe( + `SELECT attachments_json + FROM projection_thread_messages + WHERE thread_id = ? AND attachments_json IS NOT NULL`, + [threadId], + )) as ReadonlyArray; + for (const row of attachmentRows) { + const attachments = yield* decodeUnknownJsonString(String(row.attachments_json)); + if (!Array.isArray(attachments)) continue; + for (const attachment of attachments) { + if (attachment === null || typeof attachment !== "object" || Array.isArray(attachment)) { + continue; + } + const id = (attachment as Record).id; + if (typeof id === "string" && id.length > 0) { + attachmentIds.add(id); + } + } + } + + const archivedEntries = new Set( + ( + (yield* sql.unsafe( + `SELECT kind + FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks + WHERE thread_id = ? AND kind LIKE 'attachment:%'`, + [threadId], + )) as ReadonlyArray + ).flatMap((row) => { + const entry = String(row.kind).slice("attachment:".length); + return isSafeAttachmentEntry(entry) ? [entry] : []; + }), + ); const entries = yield* fs .readDirectory(config.attachmentsDir, { recursive: false }) .pipe( @@ -203,8 +234,9 @@ const make = Effect.gen(function* () { ), ); return entries.filter((entry) => { + if (archivedEntries.has(entry)) return true; const attachmentId = parseAttachmentIdFromRelativePath(entry); - return attachmentId !== null && parseThreadSegmentFromAttachmentId(attachmentId) === segment; + return attachmentId !== null && attachmentIds.has(attachmentId); }); }); @@ -317,17 +349,17 @@ const make = Effect.gen(function* () { const manifest = manifestRows[0]; const source = manifest ?? threadRows[0]; if (!source) return; + if (source.status === "cold") return; + if (source.status === "cleanup_pending") { + yield* completeArchiveCleanup(threadId); + return; + } if (threadRows.length === 0) { if (source.status === "pending" || source.status === "archiving") { yield* discardIncompleteArchive(threadId); } return; } - if (source.status === "cold") return; - if (source.status === "cleanup_pending") { - yield* completeArchiveCleanup(threadId); - return; - } if (source.status === "restored" && !allowRestored) return; const rootThreadId = String(source.root_thread_id ?? threadId); const archivedAt = String(source.archived_at ?? DateTime.formatIso(yield* DateTime.now)); @@ -646,6 +678,10 @@ const make = Effect.gen(function* () { VALUES (?, 'deleted', CURRENT_TIMESTAMP)`, [threadId], ); + // Keep the hot rows or cold chunks available until external cleanup has + // succeeded so an interrupted delete can recover exact attachment owners. + yield* removeAttachments(threadId); + yield* removeProviderLogsImpl(threadId); yield* sql.withTransaction( Effect.gen(function* () { yield* sql.unsafe( @@ -674,8 +710,6 @@ const make = Effect.gen(function* () { yield* sql.unsafe(`DELETE FROM thread_archive_manifests WHERE thread_id = ?`, [threadId]); }), ); - yield* removeAttachments(threadId); - yield* removeProviderLogsImpl(threadId); yield* reclaimFreePages(); yield* sql.unsafe(`DELETE FROM thread_cleanup_queue WHERE thread_id = ?`, [threadId]); }); From cebc5c4775d49b99eb4a4481f01792bef4d157c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 16 Jul 2026 21:14:22 +0100 Subject: [PATCH 08/63] Preserve fork storage migration metadata --- apps/mobile/app.config.ts | 6 +++--- apps/server/src/persistence/Migrations.ts | 8 ++++---- ...cle.test.ts => 035_036_ThreadStorageLifecycle.test.ts} | 4 ++-- ...{033_ThreadColdArchive.ts => 035_ThreadColdArchive.ts} | 0 ...adCleanupQueue.ts => 036_DeletedThreadCleanupQueue.ts} | 0 5 files changed, 9 insertions(+), 9 deletions(-) rename apps/server/src/persistence/Migrations/{033_034_ThreadStorageLifecycle.test.ts => 035_036_ThreadStorageLifecycle.test.ts} (95%) rename apps/server/src/persistence/Migrations/{033_ThreadColdArchive.ts => 035_ThreadColdArchive.ts} (100%) rename apps/server/src/persistence/Migrations/{034_DeletedThreadCleanupQueue.ts => 036_DeletedThreadCleanupQueue.ts} (100%) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c27be2e357f..e06c92dbcf0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -160,7 +160,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: true, - url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", + url: "https://u.expo.dev/c65ac46d-6488-49af-b61e-ab9bef78f96e", checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, }, @@ -331,10 +331,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + projectId: "c65ac46d-6488-49af-b61e-ab9bef78f96e", }, }, - owner: "pingdotgg", + owner: "quicksaver", }; export default config; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 244aa9d00dd..8870f16ca6c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,8 +45,8 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; -import Migration0033 from "./Migrations/033_ThreadColdArchive.ts"; -import Migration0034 from "./Migrations/034_DeletedThreadCleanupQueue.ts"; +import Migration0035 from "./Migrations/035_ThreadColdArchive.ts"; +import Migration0036 from "./Migrations/036_DeletedThreadCleanupQueue.ts"; /** * Migration loader with all migrations defined inline. @@ -91,8 +91,8 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], - [33, "ThreadColdArchive", Migration0033], - [34, "DeletedThreadCleanupQueue", Migration0034], + [35, "ThreadColdArchive", Migration0035], + [36, "DeletedThreadCleanupQueue", Migration0036], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts b/apps/server/src/persistence/Migrations/035_036_ThreadStorageLifecycle.test.ts similarity index 95% rename from apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts rename to apps/server/src/persistence/Migrations/035_036_ThreadStorageLifecycle.test.ts index 17619226a70..7b8cff58d54 100644 --- a/apps/server/src/persistence/Migrations/033_034_ThreadStorageLifecycle.test.ts +++ b/apps/server/src/persistence/Migrations/035_036_ThreadStorageLifecycle.test.ts @@ -8,7 +8,7 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); -layer("033-034 thread storage lifecycle migrations", (it) => { +layer("035-036 thread storage lifecycle migrations", (it) => { it.effect("queues archived and deleted threads", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -41,7 +41,7 @@ layer("033-034 thread storage lifecycle migrations", (it) => { deletedAt: "2026-07-03T00:00:00.000Z", }); - yield* runMigrations({ toMigrationInclusive: 34 }); + yield* runMigrations({ toMigrationInclusive: 36 }); const manifests = yield* sql<{ readonly threadId: string; diff --git a/apps/server/src/persistence/Migrations/033_ThreadColdArchive.ts b/apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts similarity index 100% rename from apps/server/src/persistence/Migrations/033_ThreadColdArchive.ts rename to apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts diff --git a/apps/server/src/persistence/Migrations/034_DeletedThreadCleanupQueue.ts b/apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts similarity index 100% rename from apps/server/src/persistence/Migrations/034_DeletedThreadCleanupQueue.ts rename to apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts From 70cc4f11c4bc0cbdaa0face2dc2daebaf969cad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 16 Jul 2026 22:45:38 +0100 Subject: [PATCH 09/63] Remove unrelated fork deployment metadata --- apps/mobile/app.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e06c92dbcf0..c27be2e357f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -160,7 +160,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: true, - url: "https://u.expo.dev/c65ac46d-6488-49af-b61e-ab9bef78f96e", + url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, }, @@ -331,10 +331,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "c65ac46d-6488-49af-b61e-ab9bef78f96e", + projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", }, }, - owner: "quicksaver", + owner: "pingdotgg", }; export default config; From 1ace10b62c42ba169720304263b485b2b409b96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 17 Jul 2026 00:27:34 +0100 Subject: [PATCH 10/63] Hide optimistically archived sidebar threads - Reuse archive filtering for project rows and navigation - Cover persisted and optimistic archive visibility --- apps/web/src/components/Sidebar.logic.test.ts | 23 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 13 +++++++++++ apps/web/src/components/Sidebar.tsx | 23 +++++++++---------- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..4facf5eeed7 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { createThreadJumpHintVisibilityController, + filterVisibleSidebarThreads, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -28,6 +29,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -841,6 +843,27 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("filterVisibleSidebarThreads", () => { + it("excludes archived shells and optimistically archived threads", () => { + const visibleThread = makeThread({ id: ThreadId.make("thread-visible") }); + const optimisticThread = makeThread({ id: ThreadId.make("thread-optimistic") }); + const archivedThread = makeThread({ + id: ThreadId.make("thread-archived"), + archivedAt: "2026-03-09T10:05:00.000Z", + }); + const optimisticThreadKey = scopedThreadKey( + scopeThreadRef(optimisticThread.environmentId, optimisticThread.id), + ); + + expect( + filterVisibleSidebarThreads( + [visibleThread, optimisticThread, archivedThread], + new Set([optimisticThreadKey]), + ).map((thread) => thread.id), + ).toEqual([visibleThread.id]); + }); +}); + describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..50ef8aea6f3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,6 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { getThreadSortTimestamp, sortThreads, @@ -490,6 +491,18 @@ export function getVisibleThreadsForProject>(input: }; } +export function filterVisibleSidebarThreads< + T extends Pick, +>(threads: readonly T[], optimisticallyArchivedThreadKeys: ReadonlySet): T[] { + return threads.filter( + (thread) => + thread.archivedAt === null && + !optimisticallyArchivedThreadKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); +} + export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ec25726a8bd..ba39773b33e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -185,6 +185,7 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { + filterVisibleSidebarThreads, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -1177,6 +1178,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); const openPrLink = useOpenPrLink(); const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); + const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( + (state) => state.threadKeys, + ); const sidebarThreadByKey = useMemo( () => new Map( @@ -1267,7 +1271,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + filterVisibleSidebarThreads(projectThreads, optimisticallyArchivedThreadKeys), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -1280,7 +1284,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [optimisticallyArchivedThreadKeys, projectThreads, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -3373,14 +3377,7 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => - sidebarThreads.filter( - (thread) => - thread.archivedAt === null && - !optimisticallyArchivedThreadKeys.has( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), + () => filterVisibleSidebarThreads(sidebarThreads, optimisticallyArchivedThreadKeys), [optimisticallyArchivedThreadKeys, sidebarThreads], ); const sortedProjects = useMemo(() => { @@ -3419,8 +3416,9 @@ export default function Sidebar() { () => sortedProjects.flatMap((project) => { const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, + filterVisibleSidebarThreads( + threadsByProjectKey.get(project.projectKey) ?? [], + optimisticallyArchivedThreadKeys, ), sidebarThreadSortOrder, ); @@ -3456,6 +3454,7 @@ export default function Sidebar() { sidebarThreadSortOrder, sidebarThreadPreviewCount, expandedThreadListsByProject, + optimisticallyArchivedThreadKeys, projectExpandedById, routeThreadKey, sortedProjects, From b8242130568c78cf994ab3665ec55d75649984f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 17 Jul 2026 13:11:53 +0100 Subject: [PATCH 11/63] Refresh shell state from an authoritative snapshot per session - Treat persisted shell data as fast-paint cache only - Resume WebSocket events from freshly loaded snapshots --- packages/client-runtime/src/rpc/client.ts | 101 ++++++++++-------- .../src/state/shell-sync.test.ts | 72 ++++++++++--- packages/client-runtime/src/state/shell.ts | 55 ++++------ .../src/state/shellSnapshotHttp.ts | 3 +- 4 files changed, 144 insertions(+), 87 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..bec422ab6e5 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -147,9 +147,9 @@ export function runStream( ); } -export function subscribe( +export function subscribeWithInputEffect( tag: TTag, - input: EnvironmentRpcInput, + input: Effect.Effect>, options?: { readonly onExpectedFailure?: ( cause: Cause.Cause>, @@ -179,48 +179,48 @@ export function subscribe( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure > => - Stream.suspend(() => - method(input).pipe( - Stream.catchCause((cause) => { - const hasOnlyExpectedFailures = - cause.reasons.length > 0 && - cause.reasons.every((reason) => reason._tag === "Fail"); - const isTransportFailure = - hasOnlyExpectedFailures && - cause.reasons.every( - (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), - ); - if (isTransportFailure) { - return Stream.fromEffect( - Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", - { - cause: Cause.pretty(cause), - method: tag, - environmentId: supervisor.target.environmentId, - }, - ), - ).pipe(Stream.drain); + Stream.unwrap( + input.pipe(Effect.map((sessionInput) => method(sessionInput))), + ).pipe( + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( + Stream.drain, + ); + if (options.retryExpectedFailureAfter === undefined) { + return handled; } - if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { - const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( - Stream.drain, - ); - if (options.retryExpectedFailureAfter === undefined) { - return handled; - } - return handled.pipe( - Stream.concat( - Stream.fromEffect( - Effect.sleep(options.retryExpectedFailureAfter), - ).pipe(Stream.drain), + return handled.pipe( + Stream.concat( + Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( + Stream.drain, ), - Stream.concat(subscribeToSession()), - ); - } - return Stream.failCause(cause); - }), - ), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), ); return subscribeToSession(); }, @@ -236,6 +236,23 @@ export function subscribe( ); } +export function subscribe( + tag: TTag, + input: EnvironmentRpcInput, + options?: { + readonly onExpectedFailure?: ( + cause: Cause.Cause>, + ) => Effect.Effect; + readonly retryExpectedFailureAfter?: Duration.Input; + }, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeWithInputEffect(tag, Effect.succeed(input), options); +} + export const config = Effect.gen(function* () { const session = yield* currentSession(); return yield* session.initialConfig; diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b6eb4e9f710..7908056f4d1 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -1,6 +1,9 @@ import { EnvironmentId, ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, type OrchestrationShellSnapshot, type OrchestrationShellStreamItem, } from "@t3tools/contracts"; @@ -137,21 +140,59 @@ describe("environment shell synchronization", () => { }), ); - it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => + it.effect("refreshes a warm shell cache for every WebSocket session", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], + threads: [ + { + id: ThreadId.make("stale-archived-thread"), + projectId: ProjectId.make("project-1"), + title: "Stale archived thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ], + updatedAt: "2026-06-05T00:00:00.000Z", + }; + const firstAuthoritativeSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 8, + projects: [], + threads: [], + updatedAt: "2026-06-08T00:00:00.000Z", + }; + const reconnectedAuthoritativeSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 13, + projects: [], threads: [], - updatedAt: "2026-06-06T00:00:00.000Z", + updatedAt: "2026-06-13T00:00:00.000Z", }; const events = yield* Queue.unbounded(); - const capturedAfterSequence = yield* SubscriptionRef.make(undefined); + const capturedAfterSequences = yield* Queue.unbounded(); + const authoritativeSnapshots = yield* Queue.unbounded(); + yield* Queue.offer(authoritativeSnapshots, firstAuthoritativeSnapshot); + yield* Queue.offer(authoritativeSnapshots, reconnectedAuthoritativeSnapshot); const loaderCalls = yield* SubscriptionRef.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => Stream.unwrap( - SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe( + Queue.offer(capturedAfterSequences, input.afterSequence).pipe( Effect.as(Stream.fromQueue(events)), ), ), @@ -183,22 +224,29 @@ describe("environment shell synchronization", () => { }); const snapshotLoader = ShellSnapshotLoader.of({ load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), + SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( + Effect.andThen(Queue.take(authoritativeSnapshots)), + Effect.map(Option.some), + ), }); - yield* makeEnvironmentShellState().pipe( + const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), ); - // Wait until the subscription is established from the warm cache. - yield* SubscriptionRef.changes(capturedAfterSequence).pipe( - Stream.filter((value) => value !== undefined), - Stream.runHead, + expect(yield* Queue.take(capturedAfterSequences)).toBe(8); + expect(Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot)).toEqual( + firstAuthoritativeSnapshot, ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + expect(yield* Queue.take(capturedAfterSequences)).toBe(13); + expect(Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot)).toEqual( + reconnectedAuthoritativeSnapshot, + ); + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(2); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index faa70bc4f3a..5d19504daee 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -18,7 +18,7 @@ import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import { subscribe } from "../rpc/client.ts"; +import { subscribeWithInputEffect } from "../rpc/client.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -151,39 +151,30 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Effect.forkScoped( Effect.gen(function* () { - // Establish the base shell snapshot to resume from, minimizing bytes over - // the wire: - // - Warm cache: reuse the cached snapshot (zero network) and resume via - // `afterSequence` so we only receive shell events since the cached - // sequence. - // - Cold cache: load the full shell snapshot over HTTP (gzip-compressible, - // and off the socket), then resume via `afterSequence`. - // If no base can be established we fall back to the socket-embedded - // snapshot so the shell still synchronizes. Overlapping/replayed events are - // deduped by sequence in applyItem. - const base = Option.isSome(cachedSnapshot) - ? cachedSnapshot - : yield* Effect.gen(function* () { - const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((current) => current.value), - Stream.runHead, - ); - return Option.isSome(prepared) - ? yield* snapshotLoader.load(prepared.value) - : Option.none(); - }); - - if (Option.isSome(base)) { - yield* applyItem({ kind: "snapshot", snapshot: base.value }); - } - - const subscribeInput = Option.match(base, { - onNone: () => ({}), - onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }), + // The cache is only a fast paint. Establish a fresh, authoritative base + // for every WebSocket session before resuming the shell event stream. + // Event history may be compacted (for example when archived threads move + // to cold storage), so replaying exclusively from a warm cache cannot + // prove that cached projects and threads still exist. + const subscriptionInput = Effect.gen(function* () { + const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((current) => current.value), + Stream.runHead, + ); + const snapshot = Option.isSome(prepared) + ? yield* snapshotLoader.load(prepared.value) + : Option.none(); + if (Option.isNone(snapshot)) { + // Asking for no sequence makes the socket return its authoritative + // snapshot instead of attempting an incomplete cache replay. + return {}; + } + yield* applyItem({ kind: "snapshot", snapshot: snapshot.value }); + return { afterSequence: snapshot.value.snapshotSequence }; }); - yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { + yield* subscribeWithInputEffect(ORCHESTRATION_WS_METHODS.subscribeShell, subscriptionInput, { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), }).pipe(Stream.runForEach(applyItem)); }), diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index b0a492a1305..ff6ad0f0504 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -51,7 +51,8 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( * Loads the environment shell snapshot over HTTP, returning `Option.none()` when * it cannot be loaded (so the caller falls back to the socket-embedded snapshot). * Decouples the shell state machine from the underlying HTTP + DPoP details and - * keeps them out of test contexts. + * keeps them out of test contexts. Callers refresh this snapshot for every + * WebSocket session; the persisted cache is only an immediate rendering source. */ export class ShellSnapshotLoader extends Context.Service< ShellSnapshotLoader, From d919a1ba46bf88735467778eeac54b52800d2d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 17 Jul 2026 19:30:23 +0100 Subject: [PATCH 12/63] Document conversation data savings branch - Describe cold archive, restoration, and deletion behavior - Record sidebar consistency requirements and development ports --- BRANCH_DETAILS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 BRANCH_DETAILS.md diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..2030d577de3 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,31 @@ +# Conversation Data Savings + +Archived conversations use cold storage instead of retaining full hot projections and diagnostics in `state.sqlite`. + +- `state.sqlite` keeps only the lightweight archived thread shell needed by the Archive page. Conversation events, messages, activities, turns, checkpoints, plans, session/runtime rows, and content attachments are written as bounded gzip-compressed chunks in the separate `archive.sqlite` database, then removed from hot storage. +- Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. +- Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. +- Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. +- Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction, require provider/terminal/log-writer shutdown to succeed, and keep retry state for filesystem failures other than a genuinely missing directory. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. +- Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. +- Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. +- After the legacy queues drain, a retryable one-time `VACUUM` physically compacts `state.sqlite` and enables incremental auto-vacuum. Later lifecycle operations reclaim bounded free-page batches from both `state.sqlite` and `archive.sqlite`, avoiding a full compaction on every archive. +- Normal provider, server, trace, and terminal logging behavior is unchanged. Space is reclaimed at the conversation lifecycle boundary instead of by weakening diagnostics for active work. + +Primary files: + +- `apps/server/src/orchestration/Layers/ThreadColdStorage.ts` +- `apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` +- `apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts` +- `apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts` + +## Sidebar And Shell Consistency + +Sidebar archive visibility is centralized across persisted and optimistic archive states. Project rows, project status, sorting, keyboard navigation, and prewarming exclude an optimistically archived conversation immediately, so durable cold-storage work cannot leave a stale row or keyboard target visible while the server shell catches up. + +Persisted web/mobile sidebar data is a fast paint only. Every WebSocket session refreshes an authoritative shell snapshot over HTTP, or requests the socket-embedded snapshot if HTTP fails, before replaying live changes. Because cold archive storage compacts per-thread orchestration events, a cached client must never depend on that compacted history to discover new conversations or remove archived/deleted ones. + +## Development Ports + +- Web: `5736` +- Server/WebSocket: `13776` From 2db64654b3762813f8c549a695e87657657597ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 18 Jul 2026 23:20:08 +0100 Subject: [PATCH 13/63] Document upstream integration state --- BRANCH_DETAILS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 2030d577de3..47fbbc42dda 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -25,6 +25,13 @@ Sidebar archive visibility is centralized across persisted and optimistic archiv Persisted web/mobile sidebar data is a fast paint only. Every WebSocket session refreshes an authoritative shell snapshot over HTTP, or requests the socket-embedded snapshot if HTTP fails, before replaying live changes. Because cold archive storage compacts per-thread orchestration events, a cached client must never depend on that compacted history to discover new conversations or remove archived/deleted ones. +## Upstream Integration + +- Generated from `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e`, fetched and merged on 2026-07-18 by merge commit `ddff9d2e5`. +- Relative to `upstream/main`, this branch is 15 commits ahead and 0 behind; its customization delta is 29 files, 2,395 insertions, and 206 deletions. +- Upstream's initial-snapshot event replay fix (`c14a5ca49`) and deferred active-thread cache writes (`765e1b5fc`) complement rather than replace the authoritative shell refresh: replay remains lossless while every WebSocket session still reconciles archived/deleted thread shells. +- The overlapping sidebar visibility, sorting, and Settings-panel changes merged cleanly without manual conflict resolution; no branch customizations were introduced or retired by the merge. + ## Development Ports - Web: `5736` From e9cffa4bbeec1d0ec71947d5ffe7458ae394b8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 18 Jul 2026 23:36:02 +0100 Subject: [PATCH 14/63] Harden archive shell integration after upstream merge --- BRANCH_DETAILS.md | 6 +- .../archive/ArchivedThreadsScreen.tsx | 19 +++++-- .../archive/archivedThreadList.test.ts | 8 ++- .../features/archive/archivedThreadList.ts | 5 ++ apps/server/src/server.test.ts | 56 +++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 47fbbc42dda..02938056ea8 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -25,10 +25,14 @@ Sidebar archive visibility is centralized across persisted and optimistic archiv Persisted web/mobile sidebar data is a fast paint only. Every WebSocket session refreshes an authoritative shell snapshot over HTTP, or requests the socket-embedded snapshot if HTTP fails, before replaying live changes. Because cold archive storage compacts per-thread orchestration events, a cached client must never depend on that compacted history to discover new conversations or remove archived/deleted ones. +Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived, matching the merged web timestamp safeguards without changing general mobile time rendering. + +HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up is still reading persisted events; a server regression test exercises that exact handoff without coupling the independent cold-storage and thread-detail cache state machines. + ## Upstream Integration - Generated from `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e`, fetched and merged on 2026-07-18 by merge commit `ddff9d2e5`. -- Relative to `upstream/main`, this branch is 15 commits ahead and 0 behind; its customization delta is 29 files, 2,395 insertions, and 206 deletions. +- Relative to `upstream/main`, this branch is 16 commits ahead and 0 behind; its customization delta is 32 files, 2,480 insertions, and 213 deletions. - Upstream's initial-snapshot event replay fix (`c14a5ca49`) and deferred active-thread cache writes (`765e1b5fc`) complement rather than replace the authoritative shell refresh: replay remains lossless while every WebSocket session still reconciles archived/deleted thread shells. - The overlapping sidebar visibility, sorting, and Settings-panel changes merged cleanly without manual conflict resolution; no branch customizations were introduced or retired by the merge. diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 7f455002c96..e3974629b17 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -26,11 +26,14 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { relativeTime } from "../../lib/time"; 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 { + formatArchivedThreadRelativeTime, + type ArchivedThreadGroup, + type ArchivedThreadSortOrder, +} from "./archivedThreadList"; import { scopedThreadKey } from "../../lib/scopedEntities"; export interface ArchivedThreadsHeaderEnvironment { @@ -398,7 +401,9 @@ function ArchivedThreadRow(props: { const cardColor = useThemeColor("--color-card"); const iconColor = useThemeColor("--color-icon-subtle"); const separatorColor = useThemeColor("--color-separator"); - const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); + const timestamp = formatArchivedThreadRelativeTime( + props.thread.archivedAt ?? props.thread.updatedAt, + ); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); @@ -458,9 +463,11 @@ function ArchivedThreadRow(props: { > {props.thread.title} - - {timestamp} - + {timestamp ? ( + + {timestamp} + + ) : null} {subtitle.length > 0 ? ( diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..6ca124a7804 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -3,7 +3,7 @@ import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3too import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { buildArchivedThreadGroups } from "./archivedThreadList"; +import { buildArchivedThreadGroups, formatArchivedThreadRelativeTime } from "./archivedThreadList"; const environmentId = EnvironmentId.make("environment-1"); @@ -142,3 +142,9 @@ describe("buildArchivedThreadGroups", () => { expect(result).toEqual([]); }); }); + +describe("formatArchivedThreadRelativeTime", () => { + it("omits invalid archive timestamps instead of presenting them as recent", () => { + expect(formatArchivedThreadRelativeTime("not-a-timestamp")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 6146bba2044..520545ec82f 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -10,6 +10,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { relativeTime } from "../../lib/time"; export type ArchivedThreadSortOrder = "newest" | "oldest"; @@ -24,6 +25,10 @@ function archiveTimestamp(thread: EnvironmentThreadShell): number { return Number.isNaN(timestamp) ? 0 : timestamp; } +export function formatArchivedThreadRelativeTime(input: string): string | null { + return Number.isNaN(Date.parse(input)) ? null : relativeTime(input); +} + function matchesQuery(value: string | null, query: string): boolean { return value?.toLocaleLowerCase().includes(query) ?? false; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b1f6c1f7251..da52deb9ea0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5607,6 +5607,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("buffers archived shell removals published while replay catches up", () => + Effect.gen(function* () { + const liveEvents = yield* PubSub.unbounded(); + const archivedEvent = { + sequence: 2, + eventId: EventId.make("event-archived"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("command-archive"), + causationEventId: null, + correlationId: CommandId.make("command-archive"), + metadata: {}, + type: "thread.archived", + payload: { + threadId: defaultThreadId, + archivedAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + readEvents: () => + Stream.unwrap( + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publish(liveEvents, archivedEvent); + return Stream.empty; + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + // Represents the authoritative shell snapshot already loaded over HTTP. + afterSequence: 1, + }).pipe(Stream.take(1), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + const item = items[0]; + if (item?.kind !== "thread-removed") { + assert.fail(`Expected thread-removed, received ${item?.kind ?? "no item"}`); + } + assert.equal(item.sequence, 2); + assert.equal(item.threadId, defaultThreadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("buffers thread events published while the initial snapshot loads", () => Effect.gen(function* () { const thread = makeDefaultOrchestrationReadModel().threads[0]!; From 269c8b8186f5ba34a2c4c5ebeffec4ac21921fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 10:40:39 +0100 Subject: [PATCH 15/63] Clarify conversation data savings behavior - Document authoritative shell refresh, event replay, and deferred cache writes - Preserve mobile archive timestamp and shell subscription safeguards --- BRANCH_DETAILS.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 02938056ea8..1232690b2fe 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -25,16 +25,11 @@ Sidebar archive visibility is centralized across persisted and optimistic archiv Persisted web/mobile sidebar data is a fast paint only. Every WebSocket session refreshes an authoritative shell snapshot over HTTP, or requests the socket-embedded snapshot if HTTP fails, before replaying live changes. Because cold archive storage compacts per-thread orchestration events, a cached client must never depend on that compacted history to discover new conversations or remove archived/deleted ones. -Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived, matching the merged web timestamp safeguards without changing general mobile time rendering. +Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` complement rather than replace the authoritative shell refresh. Replay remains lossless while every WebSocket session still reconciles archived and deleted thread shells. -HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up is still reading persisted events; a server regression test exercises that exact handoff without coupling the independent cold-storage and thread-detail cache state machines. - -## Upstream Integration +Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived. General mobile time rendering is unchanged. -- Generated from `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e`, fetched and merged on 2026-07-18 by merge commit `ddff9d2e5`. -- Relative to `upstream/main`, this branch is 16 commits ahead and 0 behind; its customization delta is 32 files, 2,480 insertions, and 213 deletions. -- Upstream's initial-snapshot event replay fix (`c14a5ca49`) and deferred active-thread cache writes (`765e1b5fc`) complement rather than replace the authoritative shell refresh: replay remains lossless while every WebSocket session still reconciles archived/deleted thread shells. -- The overlapping sidebar visibility, sorting, and Settings-panel changes merged cleanly without manual conflict resolution; no branch customizations were introduced or retired by the merge. +HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up is still reading persisted events; a server regression test exercises that exact handoff without coupling the independent cold-storage and thread-detail cache state machines. ## Development Ports From 99dbeb754cca42c5fa99f1a1355411715445b3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 12:28:26 +0100 Subject: [PATCH 16/63] Support inspecting cold archive SQLite state --- .agents/skills/test-t3-app/SKILL.md | 2 +- .../test-t3-app/references/sqlite-fixtures.md | 13 +++++- apps/server/scripts/t3-sqlite-state.test.ts | 40 +++++++++++++++---- apps/server/scripts/t3-sqlite-state.ts | 19 +++++++-- docs/reference/scripts.md | 2 +- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index c573807d615..b6ee9b021bb 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -49,7 +49,7 @@ Always pass `--dev-url` for a dev-runner environment so the generated pairing UR Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database. -- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks. +- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks. It targets `state.sqlite` by default; pass `--database archive` when inspecting cold archive manifests or chunks. - Stop the dev server before using `node apps/server/scripts/t3-sqlite-state.ts exec`, then restart it with the same base directory. - Seed projection tables only for disposable UI fixtures. Use application commands and APIs when testing business behavior or projection correctness. - Use the auth CLI, not direct `auth_*` table edits, for pairing and sessions. diff --git a/.agents/skills/test-t3-app/references/sqlite-fixtures.md b/.agents/skills/test-t3-app/references/sqlite-fixtures.md index 8eca543cf97..0b0179383e8 100644 --- a/.agents/skills/test-t3-app/references/sqlite-fixtures.md +++ b/.agents/skills/test-t3-app/references/sqlite-fixtures.md @@ -4,7 +4,7 @@ Load this reference only when inspecting or seeding local T3 state directly. ## Select the correct database -When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata` and the database path is `/userdata/state.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. +When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata`. Hot projections and runtime state are stored in `state.sqlite`; cold archive manifests and compressed chunks are stored in `archive.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection. @@ -18,6 +18,15 @@ node apps/server/scripts/t3-sqlite-state.ts query \ --sql "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name" ``` +The helper targets `state.sqlite` by default. Select the cold archive database explicitly for read-only inspection: + +```bash +node apps/server/scripts/t3-sqlite-state.ts query \ + --base-dir \ + --database archive \ + --sql "SELECT thread_id, status, chunk_count FROM archive_threads ORDER BY thread_id" +``` + Inspect current columns before writing a fixture: ```bash @@ -36,6 +45,8 @@ node apps/server/scripts/t3-sqlite-state.ts exec \ Use one statement per invocation for both `query` and `exec`; the helper wraps writes in a transaction and prints the backup path after a successful mutation. Use a single insert with multiple value rows when a fixture needs several records. +Cold archive manifests and chunks are authoritative recovery data. Prefer `--database archive` for queries only. Exercise archive and restore behavior through application commands and APIs instead of constructing or changing archive bundles directly; only use archive writes for a deliberately isolated corruption or compatibility fixture. + ## Seed projection data carefully The web UI primarily reads these projection tables: diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts index d1ef1368918..a5582ff4c50 100644 --- a/apps/server/scripts/t3-sqlite-state.test.ts +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -5,21 +5,24 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as ServerConfig from "../src/config.ts"; import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; -import { runSqliteState } from "./t3-sqlite-state.ts"; +import { runSqliteState, type SqliteStateDatabase } from "./t3-sqlite-state.ts"; const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( baseDir: string, + database: SqliteStateDatabase = "state", ) { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = path.join(baseDir, "userdata"); - const databasePath = path.join(stateDir, "state.sqlite"); - yield* fs.makeDirectory(stateDir, { recursive: true }); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { + baseDirIsExplicit: true, + }); + const databasePath = database === "archive" ? derivedPaths.archiveDbPath : derivedPaths.dbPath; + yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true }); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`CREATE TABLE fixtures (id INTEGER PRIMARY KEY, label TEXT NOT NULL)`; - yield* sql`INSERT INTO fixtures (id, label) VALUES (1, 'existing')`; + yield* sql`INSERT INTO fixtures (id, label) VALUES (1, ${database})`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); }); @@ -68,7 +71,30 @@ it.layer(NodeServices.layer)("t3-sqlite-state", (it) => { assert.equal(result.operation, "query"); if (result.operation === "query") { - assert.deepStrictEqual(result.rows, [{ id: 1, label: "existing" }]); + assert.deepStrictEqual(result.rows, [{ id: 1, label: "state" }]); + } + }), + ); + + it.effect("selects the cold archive database through shared server paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-archive-query-" }); + yield* createFixtureDatabase(baseDir); + yield* createFixtureDatabase(baseDir, "archive"); + + const result = yield* runSqliteState({ + operation: "query", + baseDir, + database: "archive", + sql: "SELECT id, label FROM fixtures", + }); + + assert.equal(result.database, path.join(baseDir, "userdata", "archive.sqlite")); + assert.equal(result.operation, "query"); + if (result.operation === "query") { + assert.deepStrictEqual(result.rows, [{ id: 1, label: "archive" }]); } }), ); diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index de0402b3647..0d9b844efa9 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -15,10 +15,13 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Argument, Command, Flag } from "effect/unstable/cli"; +import * as ServerConfig from "../src/config.ts"; import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; export const SqliteStateOperation = Schema.Literals(["query", "exec"]); export type SqliteStateOperation = typeof SqliteStateOperation.Type; +export const SqliteStateDatabase = Schema.Literals(["state", "archive"]); +export type SqliteStateDatabase = typeof SqliteStateDatabase.Type; export class SqliteStateMultipleSqlSourcesError extends Schema.TaggedErrorClass()( "SqliteStateMultipleSqlSourcesError", @@ -120,6 +123,7 @@ type RawSqliteRow = Readonly>; export interface RunSqliteStateInput { readonly operation: SqliteStateOperation; readonly baseDir: string; + readonly database?: SqliteStateDatabase | undefined; readonly sql?: string | undefined; readonly file?: string | undefined; } @@ -183,7 +187,11 @@ export const runSqliteState = Effect.fn("runSqliteState")(function* ( const path = yield* Path.Path; const baseDir = path.resolve(input.baseDir); const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); - const databasePath = path.join(baseDir, "userdata", "state.sqlite"); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { + baseDirIsExplicit: true, + }); + const databasePath = + input.database === "archive" ? derivedPaths.archiveDbPath : derivedPaths.dbPath; const source = yield* resolveSqlSource(input.sql, input.file); if (!(yield* fs.exists(databasePath))) { @@ -253,7 +261,11 @@ export const t3SqliteStateCommand = Command.make( Argument.withDescription("Run a read-only query or a backed-up fixture mutation."), ), baseDir: Flag.string("base-dir").pipe( - Flag.withDescription("Explicit T3 base directory containing userdata/state.sqlite."), + Flag.withDescription("Explicit T3 base directory containing runtime SQLite databases."), + ), + database: Flag.choice("database", SqliteStateDatabase.literals).pipe( + Flag.withDescription("Runtime database to inspect or seed (default: state)."), + Flag.withDefault("state"), ), sql: Flag.string("sql").pipe( Flag.optional, @@ -264,10 +276,11 @@ export const t3SqliteStateCommand = Command.make( Flag.withDescription("Path to a SQL source file."), ), }, - ({ operation, baseDir, sql, file }) => + ({ operation, baseDir, database, sql, file }) => runSqliteState({ operation, baseDir, + database, sql: Option.getOrUndefined(sql), file: Option.getOrUndefined(file), }).pipe(Effect.flatMap(encodeSqliteStateResult), Effect.flatMap(Console.log)), diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 746aa66d563..687c426f187 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -11,7 +11,7 @@ - `vp run build` — Builds contracts, web app, and server. - `vp run typecheck` — Strict TypeScript checks for all packages. - `vp run test` — Runs workspace tests. -- `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...` — Inspects or seeds an isolated T3 SQLite database; writes create a private backup first. +- `node apps/server/scripts/t3-sqlite-state.ts --base-dir [--database state|archive] ...` — Inspects or seeds an isolated T3 SQLite database, defaulting to `state`; writes create a private backup first. - `vp run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. - `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. - `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. From 17b49065a00bbf4f1e935e509b239d0753db7dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 12:31:43 +0100 Subject: [PATCH 17/63] Document cold archive inspection workflow --- BRANCH_DETAILS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 1232690b2fe..2167d6cc981 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -3,6 +3,7 @@ Archived conversations use cold storage instead of retaining full hot projections and diagnostics in `state.sqlite`. - `state.sqlite` keeps only the lightweight archived thread shell needed by the Archive page. Conversation events, messages, activities, turns, checkpoints, plans, session/runtime rows, and content attachments are written as bounded gzip-compressed chunks in the separate `archive.sqlite` database, then removed from hot storage. +- `apps/server/scripts/t3-sqlite-state.ts` is the supported isolated-database inspection path. It targets hot `state.sqlite` by default and accepts `--database archive` for cold manifest and chunk queries, with both paths derived from the shared server configuration. Archive and restore behavior should use application commands rather than direct archive-bundle writes. - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. @@ -18,6 +19,7 @@ Primary files: - `apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` - `apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts` - `apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts` +- `apps/server/scripts/t3-sqlite-state.ts` ## Sidebar And Shell Consistency From 2bba1bfe6f3834feef96a3066b029c653c5fe784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 12:45:39 +0100 Subject: [PATCH 18/63] Fix cold archive inspection query - Query existing archive manifest columns - Derive chunk counts from the archive chunk table --- .agents/skills/test-t3-app/references/sqlite-fixtures.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/test-t3-app/references/sqlite-fixtures.md b/.agents/skills/test-t3-app/references/sqlite-fixtures.md index 0b0179383e8..42959281e61 100644 --- a/.agents/skills/test-t3-app/references/sqlite-fixtures.md +++ b/.agents/skills/test-t3-app/references/sqlite-fixtures.md @@ -24,7 +24,7 @@ The helper targets `state.sqlite` by default. Select the cold archive database e node apps/server/scripts/t3-sqlite-state.ts query \ --base-dir \ --database archive \ - --sql "SELECT thread_id, status, chunk_count FROM archive_threads ORDER BY thread_id" + --sql "SELECT t.thread_id, t.archive_version, COUNT(c.chunk_index) AS chunk_count FROM archive_threads AS t LEFT JOIN archive_thread_chunks AS c ON c.thread_id = t.thread_id GROUP BY t.thread_id, t.archive_version ORDER BY t.thread_id" ``` Inspect current columns before writing a fixture: From 20b7f6c5ca42ee3c084fdfb7874418f4d5e1b874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 00:17:27 +0100 Subject: [PATCH 19/63] Enable pnpm global virtual store - Scope the Effect Vitest extension to beta.78 - Regenerate the lockfile with updated peer resolution --- pnpm-lock.yaml | 103 ++++++++++++++++++++++++++++++++++++-------- pnpm-workspace.yaml | 8 +++- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c58ccffc420..0cac51a9eb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= +packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -419,7 +419,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -471,7 +471,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -616,7 +616,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -682,7 +682,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) + version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -698,7 +698,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -726,7 +726,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -745,7 +745,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -758,7 +758,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -777,7 +777,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -799,7 +799,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -833,7 +833,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -858,7 +858,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -880,7 +880,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -911,7 +911,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2012,6 +2012,10 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 + vitest: '*' + peerDependenciesMeta: + vitest: + optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -11761,9 +11765,43 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + optionalDependencies: + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -15302,7 +15340,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): + alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15316,7 +15354,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15349,12 +15387,39 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' - '@types/node' - '@types/react' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw - pg-native + - publint - react-devtools-core + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun - utf-8-validate + - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22757702fc8..5b02480a65d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: - packages/* - scripts +enableGlobalVirtualStore: true + # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -95,9 +97,11 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - "@effect/vitest@*": + # Wildcard semver excludes prereleases. + "@effect/vitest@4.0.0-beta.78": dependencies: - vite-plus: "catalog:" + # The patched package imports vite-plus inside the shared graph. + vite-plus: 0.2.2 peerDependenciesMeta: vitest: optional: true From 7e9c03107fe551195cda7469836df191740b99de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 13:57:10 +0100 Subject: [PATCH 20/63] Test forced project cleanup of cold threads --- .../Layers/ThreadDeletionReactor.test.ts | 199 +++++++++++++++++- 1 file changed, 197 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 34b1b995a3a..274a09270bb 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -1,10 +1,41 @@ -import { ThreadId } from "@t3tools/contracts"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { assert, it as effectIt } from "@effect/vitest"; import { describe, expect, it } from "vite-plus/test"; -import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; +import * as ServerConfig from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import { decideOrchestrationCommand } from "../decider.ts"; +import { createEmptyReadModel, projectEvent } from "../projector.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; +import { + logCleanupCauseUnlessInterrupted, + ThreadDeletionReactorLive, +} from "./ThreadDeletionReactor.ts"; describe("logCleanupCauseUnlessInterrupted", () => { const threadId = ThreadId.make("thread-deletion-reactor-test"); @@ -36,3 +67,167 @@ describe("logCleanupCauseUnlessInterrupted", () => { } }); }); + +effectIt.effect("force-deleting a project removes an already-cold archived thread", () => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const eventSubscription = yield* PubSub.subscribe(events); + const now = "2026-07-20T00:00:00.000Z"; + const projectId = ProjectId.make("project-force-delete-cold"); + const threadId = ThreadId.make("thread-force-delete-cold"); + const commandId = CommandId.make("command-force-delete-cold"); + + const orchestrationEngineLayer = Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.fromSubscription(eventSubscription), + }); + const providerLayer = Layer.mock(ProviderService)({ + stopSession: () => Effect.void, + }); + const terminalLayer = Layer.mock(TerminalManager.TerminalManager)({ + close: () => Effect.void, + }); + const loggerLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); + const coldStorageLayer = ThreadColdStorageLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-force-delete-cold-" }), + ), + Layer.provideMerge(NodeServices.layer), + ); + const runtimeLayer = ThreadDeletionReactorLive.pipe( + Layer.provide(orchestrationEngineLayer), + Layer.provide(providerLayer), + Layer.provide(terminalLayer), + Layer.provide(loggerLayer), + Layer.provideMerge(coldStorageLayer), + ); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const reactor = yield* ThreadDeletionReactor; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, created_at, updated_at, archived_at + ) VALUES ( + ${threadId}, ${projectId}, 'Cold forced-delete thread', + '{"instanceId":"codex","model":"gpt-5.5","options":[]}', + 'full-access', 'default', ${now}, ${now}, ${now} + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-force-delete-cold', ${threadId}, NULL, 'user', + 'delete this cold content', '[]', 0, ${now}, ${now} + ) + `; + yield* storage.archiveThread(threadId); + const coldManifests = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + const coldChunks = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM cold_archive.archive_thread_chunks WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(coldManifests, [{ count: 1 }]); + assert.isAbove(coldChunks[0]?.count ?? 0, 0); + + let readModel = createEmptyReadModel(now); + readModel = yield* projectEvent(readModel, { + sequence: 1, + eventId: EventId.make("event-force-delete-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: now, + commandId: CommandId.make("command-force-delete-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-force-delete-project-created"), + metadata: {}, + payload: { + projectId, + title: "Force Delete Cold", + workspaceRoot: "/tmp/project-force-delete-cold", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + readModel = yield* projectEvent(readModel, { + sequence: 2, + eventId: EventId.make("event-force-delete-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("command-force-delete-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-force-delete-thread-created"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Cold forced-delete thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.5", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + readModel = yield* projectEvent(readModel, { + sequence: 3, + eventId: EventId.make("event-force-delete-thread-archived"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.archived", + occurredAt: now, + commandId: CommandId.make("command-force-delete-thread-archived"), + causationEventId: null, + correlationId: CommandId.make("command-force-delete-thread-archived"), + metadata: {}, + payload: { threadId, archivedAt: now, updatedAt: now }, + }); + + const planned = yield* decideOrchestrationCommand({ + command: { type: "project.delete", commandId, projectId, force: true }, + readModel, + }); + const plannedEvents = Array.isArray(planned) ? planned : [planned]; + const deletedEvent = plannedEvents.find( + (event): event is Extract<(typeof plannedEvents)[number], { type: "thread.deleted" }> => + event.type === "thread.deleted" && event.payload.threadId === threadId, + ); + assert.isDefined(deletedEvent); + + yield* reactor.start(); + yield* PubSub.publish(events, { ...deletedEvent, sequence: 4 }); + yield* reactor.drain; + + const hotRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_threads WHERE thread_id = ${threadId} + `; + const manifests = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + const chunks = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM cold_archive.archive_thread_chunks WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(hotRows, [{ count: 0 }]); + assert.deepStrictEqual(manifests, [{ count: 0 }]); + assert.deepStrictEqual(chunks, [{ count: 0 }]); + }).pipe(Effect.provide(runtimeLayer)); + }), +); From e04c86f5381c9d3de2461c358bdfe2bd5283c439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 13:58:58 +0100 Subject: [PATCH 21/63] Document cold cleanup after forced project removal --- BRANCH_DETAILS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index a778dbdb4cb..63181829134 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -7,6 +7,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. +- Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction, require provider/terminal/log-writer shutdown to succeed, and keep retry state for filesystem failures other than a genuinely missing directory. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. @@ -17,6 +18,7 @@ Primary files: - `apps/server/src/orchestration/Layers/ThreadColdStorage.ts` - `apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` +- `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` - `apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts` - `apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts` - `apps/server/scripts/t3-sqlite-state.ts` @@ -25,14 +27,12 @@ Primary files: Sidebar archive visibility is centralized across persisted and optimistic archive states. Project rows, project status, sorting, keyboard navigation, and prewarming exclude an optimistically archived conversation immediately, so durable cold-storage work cannot leave a stale row or keyboard target visible while the server shell catches up. -The shared client runtime refreshes an authoritative shell snapshot on each WebSocket session and when the app returns to the foreground, with socket completion markers protecting the handoff to live events. Cold archive storage relies on this upstream synchronization contract because compacted per-thread history cannot prove that cached projects and threads still exist. +Authoritative shell synchronization is shared runtime behavior in `packages/client-runtime/src/state/shell.ts`, `packages/client-runtime/src/rpc/client.ts`, and `apps/server/src/ws.ts`, rather than a branch-specific subscription implementation. It refreshes the shell snapshot on each WebSocket session and when the app returns to the foreground, with socket completion markers protecting the handoff to live events. Cold archive storage relies on this contract because compacted per-thread history cannot prove that cached projects and threads still exist. -The branch retains a server regression for archive removals published while an HTTP-seeded shell subscription catches up. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. +`apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived. General mobile time rendering is unchanged. -HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up is still reading persisted events; a server regression test exercises that exact handoff without coupling the independent cold-storage and thread-detail cache state machines. - ## Development Ports - Web: `5736` From 23624937d2020edef5c5c69fcf8fbf4336e7af44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 14:16:32 +0100 Subject: [PATCH 22/63] Synchronize cold thread deletion test --- .../src/orchestration/Layers/ThreadDeletionReactor.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 274a09270bb..89bfd766491 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -76,6 +77,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa const projectId = ProjectId.make("project-force-delete-cold"); const threadId = ThreadId.make("thread-force-delete-cold"); const commandId = CommandId.make("command-force-delete-cold"); + const deleteStarted = yield* Deferred.make(); const orchestrationEngineLayer = Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -83,7 +85,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa streamDomainEvents: Stream.fromSubscription(eventSubscription), }); const providerLayer = Layer.mock(ProviderService)({ - stopSession: () => Effect.void, + stopSession: () => Deferred.succeed(deleteStarted, undefined).pipe(Effect.asVoid), }); const terminalLayer = Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void, @@ -214,6 +216,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa yield* reactor.start(); yield* PubSub.publish(events, { ...deletedEvent, sequence: 4 }); + yield* Deferred.await(deleteStarted); yield* reactor.drain; const hotRows = yield* sql<{ readonly count: number }>` From 2cc2cac51b829b7f73fed982f0beee5585735646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 14:24:09 +0100 Subject: [PATCH 23/63] Verify cold shell before forced project cleanup --- .../src/orchestration/Layers/ThreadDeletionReactor.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 89bfd766491..e89ae801a91 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -131,12 +131,16 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa ) `; yield* storage.archiveThread(threadId); + const coldShells = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_threads WHERE thread_id = ${threadId} + `; const coldManifests = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} `; const coldChunks = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count FROM cold_archive.archive_thread_chunks WHERE thread_id = ${threadId} `; + assert.deepStrictEqual(coldShells, [{ count: 1 }]); assert.deepStrictEqual(coldManifests, [{ count: 1 }]); assert.isAbove(coldChunks[0]?.count ?? 0, 0); From 1ceb005df4f56fee70dc8e90b7dab4984e44b78a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 14:48:57 +0100 Subject: [PATCH 24/63] Update cold storage tests for latest sequence --- .../src/orchestration/Layers/ThreadDeletionReactor.test.ts | 1 + apps/server/src/server.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index e89ae801a91..6248f4af7a0 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -83,6 +83,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.fromSubscription(eventSubscription), + latestSequence: Effect.succeed(0), }); const providerLayer = Layer.mock(ProviderService)({ stopSession: () => Deferred.succeed(deleteStarted, undefined).pipe(Effect.asVoid), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 22c77225b61..8b39fe92248 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5742,6 +5742,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { orchestrationEngine: { streamDomainEvents: Stream.fromPubSub(liveEvents), + latestSequence: Effect.succeed(2), readEvents: () => Stream.unwrap( Effect.gen(function* () { From 441a064906315ac79624c9c14388350e9cc2252a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 15:57:27 +0100 Subject: [PATCH 25/63] Protect shell sync and unarchive handoffs - Revalidate HTTP shell state with a socket-owned snapshot - Reserve hot archived rows until unarchive succeeds or rolls back - Add focused synchronization and storage regression coverage --- BRANCH_DETAILS.md | 3 +- .../Layers/ThreadColdStorage.test.ts | 38 +++++++++++++++++ .../orchestration/Layers/ThreadColdStorage.ts | 41 ++++++++++++++++++- .../src/state/shell-sync.test.ts | 38 +++++++++++------ packages/client-runtime/src/state/shell.ts | 10 ++++- 5 files changed, 113 insertions(+), 17 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 63181829134..e88b923fad7 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -10,6 +10,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction, require provider/terminal/log-writer shutdown to succeed, and keep retry state for filesystem failures other than a genuinely missing directory. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. +- Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. - After the legacy queues drain, a retryable one-time `VACUUM` physically compacts `state.sqlite` and enables incremental auto-vacuum. Later lifecycle operations reclaim bounded free-page batches from both `state.sqlite` and `archive.sqlite`, avoiding a full compaction on every archive. - Normal provider, server, trace, and terminal logging behavior is unchanged. Space is reclaimed at the conversation lifecycle boundary instead of by weakening diagnostics for active work. @@ -27,7 +28,7 @@ Primary files: Sidebar archive visibility is centralized across persisted and optimistic archive states. Project rows, project status, sorting, keyboard navigation, and prewarming exclude an optimistically archived conversation immediately, so durable cold-storage work cannot leave a stale row or keyboard target visible while the server shell catches up. -Authoritative shell synchronization is shared runtime behavior in `packages/client-runtime/src/state/shell.ts`, `packages/client-runtime/src/rpc/client.ts`, and `apps/server/src/ws.ts`, rather than a branch-specific subscription implementation. It refreshes the shell snapshot on each WebSocket session and when the app returns to the foreground, with socket completion markers protecting the handoff to live events. Cold archive storage relies on this contract because compacted per-thread history cannot prove that cached projects and threads still exist. +Authoritative shell synchronization is shared runtime behavior in `packages/client-runtime/src/state/shell.ts`, `packages/client-runtime/src/rpc/client.ts`, and `apps/server/src/ws.ts`, rather than a branch-specific subscription implementation. HTTP snapshots provide an early shell, while completion-capable WebSocket sessions revalidate it with a socket-owned snapshot after live buffering begins; the same refresh runs when the app returns to the foreground. Cold archive storage relies on this contract because compacted per-thread history cannot prove that cached projects and threads still exist. `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 7d088b679f8..397841c2b2e 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -53,6 +53,44 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("reserves hot archived rows while an unarchive command is pending", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-unarchive-hot-reservation"); + + yield* insertArchivedThread(threadId, "Pending hot unarchive"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-unarchive-hot-reservation', ${threadId}, NULL, 'user', + 'keep hot until unarchive commits', '[]', 0, + '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + + assert.isTrue(yield* storage.restoreTree(threadId)); + yield* storage.archiveThread(threadId); + + const messages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + const manifest = yield* sql<{ readonly status: string }>` + SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(messages, [{ text: "keep hot until unarchive commits" }]); + assert.deepStrictEqual(manifest, [{ status: "restored" }]); + + yield* storage.finishRestoreTree(threadId); + const remainingManifest = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM thread_archive_manifests WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(remainingManifest, [{ count: 0 }]); + }), + ); + it.effect("compresses conversation data, destroys logs, restores content, and hard-deletes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index bfc2b0272ed..f29bacab04d 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -615,7 +615,7 @@ const make = Effect.gen(function* () { const restoreTreeImpl = Effect.fn("restoreArchiveTreeImpl")(function* (threadId: ThreadId) { const rootThreadId = yield* resolveTreeRoot(threadId); const rows = (yield* sql.unsafe( - `SELECT thread_id + `SELECT thread_id, status FROM thread_archive_manifests WHERE root_thread_id = ? AND status IN ('cleanup_pending', 'cold', 'restored') ORDER BY CASE WHEN thread_id = ? THEN 1 ELSE 0 END, thread_id ASC`, @@ -625,7 +625,44 @@ const make = Effect.gen(function* () { for (const row of rows) { restored = (yield* restoreThread(ThreadId.make(String(row.thread_id)))) || restored; } - return restored; + if (restored || rows.some((row) => row.status === "restored")) { + return true; + } + if (rows.length > 0) { + return false; + } + + // The lifecycle worker may not have started archiving this shell yet. Mark + // its still-hot rows as owned by the unarchive command before releasing the + // tree lock, so a queued archive job cannot delete them before that command + // commits. A failed command rolls this reservation back through archiveImpl; + // a successful command removes it through finishRestoreTreeImpl. + return yield* sql.withTransaction( + Effect.gen(function* () { + const archivedShell = (yield* sql.unsafe( + `SELECT archived_at + FROM projection_threads + WHERE thread_id = ? AND deleted_at IS NULL AND archived_at IS NOT NULL + LIMIT 1`, + [threadId], + )) as ReadonlyArray; + const source = archivedShell[0]; + if (!source) return false; + + yield* sql.unsafe( + `INSERT INTO thread_archive_manifests + (thread_id, root_thread_id, status, archive_version, archived_at, updated_at, error) + VALUES (?, ?, 'restored', ?, ?, CURRENT_TIMESTAMP, NULL) + ON CONFLICT(thread_id) DO UPDATE SET + status = 'restored', + updated_at = CURRENT_TIMESTAMP, + error = NULL + WHERE thread_archive_manifests.status IN ('pending', 'archiving')`, + [threadId, rootThreadId, ARCHIVE_VERSION, String(source.archived_at)], + ); + return true; + }), + ); }); const rollbackRestoreTreeImpl = Effect.fn("rollbackRestoreArchiveTreeImpl")(function* ( diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index a4514d5f0e6..60719b6041b 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -148,7 +148,7 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("revalidates an HTTP shell snapshot with the authoritative socket snapshot", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, @@ -159,12 +159,14 @@ describe("environment shell synchronization", () => { const httpSnapshot: OrchestrationShellSnapshot = { ...cachedSnapshot, snapshotSequence: 9, - threads: [], + threads: [{ id: "archived-after-http-snapshot" } as never], updatedAt: "2026-06-07T00:00:00.000Z", }; const events = yield* Queue.unbounded(); - const capturedAfterSequence = yield* SubscriptionRef.make(undefined); - const capturedCompletionMarker = yield* Ref.make(undefined); + const capturedInput = yield* SubscriptionRef.make<{ + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + } | null>(null); const loaderCalls = yield* SubscriptionRef.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { @@ -172,10 +174,7 @@ describe("environment shell synchronization", () => { readonly requestCompletionMarker?: boolean; }) => Stream.unwrap( - Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( - Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), - Effect.as(Stream.fromQueue(events)), - ), + SubscriptionRef.set(capturedInput, input).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); @@ -215,24 +214,37 @@ describe("environment shell synchronization", () => { Effect.provideService(ShellSnapshotLoader, snapshotLoader), ); - // Wait until the subscription is established from the warm cache. - yield* SubscriptionRef.changes(capturedAfterSequence).pipe( - Stream.filter((value) => value !== undefined), + // Wait until the authoritative socket snapshot subscription is established. + yield* SubscriptionRef.changes(capturedInput).pipe( + Stream.filter((value) => value !== null), Stream.runHead, ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); - expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); + expect(yield* SubscriptionRef.get(capturedInput)).toEqual({ + requestCompletionMarker: true, + }); expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { + ...httpSnapshot, + snapshotSequence: 10, + threads: [], + updatedAt: "2026-06-07T00:00:01.000Z", + }, + }); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); + expect(Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot).threads).toEqual( + [], + ); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 6ccb11797f5..498b7fb8c17 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -204,9 +204,17 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const httpSnapshot = yield* snapshotLoader.load(prepared); if (Option.isSome(httpSnapshot)) { yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + // A cold archive can compact the event that removed a thread after + // this HTTP snapshot was read but before the socket subscribes. Newer + // servers therefore revalidate with a socket-owned snapshot, whose + // live buffer is attached before the projection is read. Keep the + // cursor resume path only for older servers that do not advertise the + // synchronized snapshot handoff. + if (supportsCompletionMarker) { + return { requestCompletionMarker: true as const }; + } return { afterSequence: httpSnapshot.value.snapshotSequence, - ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; } From 925af20866923d30d1b3134b520679e0eae1440f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 16:02:33 +0100 Subject: [PATCH 26/63] Reject unarchive without restored storage - Stop before dispatch when storage cannot restore or reserve rows - Cover the missing-archive rejection without appending an event --- BRANCH_DETAILS.md | 2 +- .../Layers/OrchestrationEngine.test.ts | 13 +++++++++++++ .../src/orchestration/Layers/OrchestrationEngine.ts | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index e88b923fad7..799d1a14e35 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -10,7 +10,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction, require provider/terminal/log-writer shutdown to succeed, and keep retry state for filesystem failures other than a genuinely missing directory. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. -- Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. +- Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. - After the legacy queues drain, a retryable one-time `VACUUM` physically compacts `state.sqlite` and enables incremental auto-vacuum. Later lifecycle operations reclaim bounded free-page batches from both `state.sqlite` and `archive.sqlite`, avoiding a full compaction on every archive. - Normal provider, server, trace, and terminal logging behavior is unchanged. Space is reclaimed at the conversation lifecycle boundary instead of by weakening diagnostics for active work. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index d2ace393bdc..b76b9d21dbb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -969,6 +969,19 @@ describe("OrchestrationEngine", () => { commandId: CommandId.make("cmd-cold-thread-archive"), threadId: ThreadId.make("thread-cold-rollback"), }); + + const missingArchiveFailure = yield* Effect.flip( + engine.dispatch({ + type: "thread.unarchive", + commandId: CommandId.make("cmd-cold-unarchive-missing"), + threadId: ThreadId.make("thread-cold-rollback"), + }), + ); + expect(missingArchiveFailure.message).toContain( + "Failed to restore the archived conversation", + ); + expect(events.at(-1)?.type).toBe("thread.archived"); + restoreOnUnarchive = true; const unarchiveFailure = yield* Effect.flip( diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 6cf82738a1e..85c33335777 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -170,6 +170,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { if (restored) { restoredUnarchiveThreadId = unarchiveThreadId; commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); + } else { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: "Failed to restore the archived conversation.", + }); } } From 430784293c73ef29c9594d713bb63504ea08dd01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 17:08:59 +0100 Subject: [PATCH 27/63] Re-archive stale restored conversations - Scope restored reservations to their original archive timestamp - Add coverage for re-archive after finalization failure --- BRANCH_DETAILS.md | 1 + .../Layers/ThreadColdStorage.test.ts | 55 +++++++++++++++++++ .../orchestration/Layers/ThreadColdStorage.ts | 15 ++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 799d1a14e35..09c932f697c 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -11,6 +11,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction, require provider/terminal/log-writer shutdown to succeed, and keep retry state for filesystem failures other than a genuinely missing directory. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. +- A `restored` manifest protects only the archive epoch that an unarchive command reserved. If post-commit bundle finalization fails and the user later archives the thread again, the newer shell timestamp lets lifecycle work replace the stale manifest and move the conversation cold. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. - After the legacy queues drain, a retryable one-time `VACUUM` physically compacts `state.sqlite` and enables incremental auto-vacuum. Later lifecycle operations reclaim bounded free-page batches from both `state.sqlite` and `archive.sqlite`, avoiding a full compaction on every archive. - Normal provider, server, trace, and terminal logging behavior is unchanged. Space is reclaimed at the conversation lifecycle boundary instead of by weakening diagnostics for active work. diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 397841c2b2e..25dfd2277f0 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -91,6 +91,61 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("re-archives hot rows after restored-bundle finalization fails", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-rearchive-stale-restored"); + + yield* insertArchivedThread(threadId, "Re-archive stale restore"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-rearchive-stale-restored', ${threadId}, NULL, 'user', + 'move cold after the next archive', '[]', 0, + '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + + assert.isTrue(yield* storage.restoreTree(threadId)); + yield* sql` + UPDATE projection_threads + SET archived_at = NULL, updated_at = '2026-07-03T00:00:00.000Z' + WHERE thread_id = ${threadId} + `; + // Simulate finishRestoreTree failing after the unarchive transaction, + // then the user archiving the same thread again. + yield* sql` + UPDATE projection_threads + SET archived_at = '2026-07-04T00:00:00.000Z', + updated_at = '2026-07-04T00:00:00.000Z' + WHERE thread_id = ${threadId} + `; + + yield* storage.archiveThread(threadId); + + const messages = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_thread_messages + WHERE thread_id = ${threadId} + `; + const manifest = yield* sql<{ + readonly status: string; + readonly archivedAt: string; + }>` + SELECT status, archived_at AS "archivedAt" + FROM thread_archive_manifests + WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(messages, [{ count: 0 }]); + assert.deepStrictEqual(manifest, [ + { status: "cold", archivedAt: "2026-07-04T00:00:00.000Z" }, + ]); + }), + ); + it.effect("compresses conversation data, destroys logs, restores content, and hard-deletes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index f29bacab04d..b6b519dafe7 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -360,9 +360,20 @@ const make = Effect.gen(function* () { } return; } - if (source.status === "restored" && !allowRestored) return; + // A restored manifest reserves this archive epoch for an in-flight + // unarchive. A later archive has a new shell timestamp and may replace a + // reservation that finishRestoreTree failed to remove after commit. + if ( + source.status === "restored" && + !allowRestored && + String(source.archived_at) === String(threadRows[0]?.archived_at) + ) { + return; + } const rootThreadId = String(source.root_thread_id ?? threadId); - const archivedAt = String(source.archived_at ?? DateTime.formatIso(yield* DateTime.now)); + const archivedAt = String( + threadRows[0]?.archived_at ?? source.archived_at ?? DateTime.formatIso(yield* DateTime.now), + ); yield* sql.unsafe( `INSERT INTO thread_archive_manifests From 0f783c5f69edc1285696a38f629ab5550e9f40d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 21 Jul 2026 23:25:58 +0100 Subject: [PATCH 28/63] Harden thread archive cleanup and evict stale detail caches --- .../Layers/ThreadDeletionReactor.test.ts | 208 +++++++++++++++++- .../Layers/ThreadDeletionReactor.ts | 104 +++++++-- apps/web/src/connection/storage.test.ts | 38 +++- apps/web/src/connection/storage.ts | 47 ++-- .../src/state/threads-sync.test.ts | 53 +++++ packages/client-runtime/src/state/threads.ts | 89 +++++--- 6 files changed, 473 insertions(+), 66 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 6248f4af7a0..459fa546207 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -13,9 +13,12 @@ import * as Deferred from "effect/Deferred"; 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 PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as TestClock from "effect/testing/TestClock"; import { assert, it as effectIt } from "@effect/vitest"; import { describe, expect, it } from "vite-plus/test"; @@ -25,19 +28,87 @@ import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderValidationError } from "../../provider/Errors.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; -import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; +import { ThreadColdStorage, ThreadColdStorageError } from "../Services/ThreadColdStorage.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; import { logCleanupCauseUnlessInterrupted, + THREAD_LIFECYCLE_RETRY_DELAY, ThreadDeletionReactorLive, } from "./ThreadDeletionReactor.ts"; +const archivedEvent = (threadId: ThreadId): OrchestrationEvent => ({ + sequence: 1, + eventId: EventId.make(`event-archive-${threadId}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.archived", + occurredAt: "2026-07-20T00:00:00.000Z", + commandId: CommandId.make(`command-archive-${threadId}`), + causationEventId: null, + correlationId: CommandId.make(`command-archive-${threadId}`), + metadata: {}, + payload: { + threadId, + archivedAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", + }, +}); + +function testReactorLayer(input: { + readonly eventStream: Stream.Stream; + readonly stopSession: ProviderService["Service"]["stopSession"]; + readonly getBinding: ProviderSessionDirectory["Service"]["getBinding"]; + readonly getProjectedSession: ProjectionThreadSessionRepository["Service"]["getByThreadId"]; + readonly archiveThread: ThreadColdStorage["Service"]["archiveThread"]; + readonly pendingArchives?: ReadonlyArray; +}) { + return ThreadDeletionReactorLive.pipe( + Layer.provide( + Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: input.eventStream, + latestSequence: Effect.succeed(0), + }), + ), + Layer.provide(Layer.mock(ProviderService)({ stopSession: input.stopSession })), + Layer.provide( + Layer.mock(ProviderSessionDirectory)({ + getBinding: input.getBinding, + }), + ), + Layer.provide( + Layer.mock(ProjectionThreadSessionRepository)({ + getByThreadId: input.getProjectedSession, + }), + ), + Layer.provide( + Layer.mock(TerminalManager.TerminalManager)({ + close: () => Effect.void, + }), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.mock(ThreadColdStorage)({ + archiveThread: input.archiveThread, + deleteThread: () => Effect.void, + compactLegacyStorage: Effect.void, + listPendingArchiveThreadIds: Effect.succeed(input.pendingArchives ?? []), + listPendingDeleteThreadIds: Effect.succeed([]), + }), + ), + ); +} + describe("logCleanupCauseUnlessInterrupted", () => { const threadId = ThreadId.make("thread-deletion-reactor-test"); @@ -69,6 +140,131 @@ describe("logCleanupCauseUnlessInterrupted", () => { }); }); +effectIt.effect("archives a settled thread when its provider binding is already absent", () => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const subscription = yield* PubSub.subscribe(events); + const threadId = ThreadId.make("thread-archive-missing-binding"); + const archived = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const layer = testReactorLayer({ + eventStream: Stream.fromSubscription(subscription), + stopSession: () => Ref.update(stopCalls, (count) => count + 1), + getBinding: () => Effect.succeed(Option.none()), + getProjectedSession: () => Effect.succeed(Option.none()), + archiveThread: () => Deferred.succeed(archived, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* PubSub.publish(events, archivedEvent(threadId)); + yield* Deferred.await(archived); + yield* reactor.drain; + + expect(yield* Ref.get(stopCalls)).toBe(0); + }).pipe(Effect.provide(layer)); + }), +); + +effectIt.effect( + "keeps archive cleanup fail-closed without a binding for an active projection", + () => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const subscription = yield* PubSub.subscribe(events); + const threadId = ThreadId.make("thread-archive-active-missing-binding"); + const stopAttempted = yield* Deferred.make(); + const archiveCalls = yield* Ref.make(0); + const layer = testReactorLayer({ + eventStream: Stream.fromSubscription(subscription), + stopSession: () => + Deferred.succeed(stopAttempted, undefined).pipe( + Effect.andThen( + Effect.fail( + new ProviderValidationError({ + operation: "ProviderService.stopSession", + issue: "missing provider binding", + }), + ), + ), + ), + getBinding: () => Effect.succeed(Option.none()), + getProjectedSession: () => + Effect.succeed( + Option.some({ + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-07-20T00:00:00.000Z", + }), + ), + archiveThread: () => Ref.update(archiveCalls, (count) => count + 1), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* PubSub.publish(events, archivedEvent(threadId)); + yield* Deferred.await(stopAttempted); + yield* reactor.drain; + + expect(yield* Ref.get(archiveCalls)).toBe(0); + }).pipe(Effect.provide(layer)); + }), +); + +effectIt.effect("retries a failed durable archive job after a delay", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-retry"); + const firstAttempt = yield* Deferred.make(); + const secondAttempt = yield* Deferred.make(); + const archiveAttempts = yield* Ref.make(0); + const layer = testReactorLayer({ + eventStream: Stream.empty, + stopSession: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + getProjectedSession: () => Effect.succeed(Option.none()), + pendingArchives: [threadId], + archiveThread: () => + Ref.updateAndGet(archiveAttempts, (count) => count + 1).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Deferred.succeed(firstAttempt, undefined).pipe( + Effect.andThen( + Effect.fail( + new ThreadColdStorageError({ + operation: "archive", + threadId, + cause: new Error("temporary archive failure"), + }), + ), + ), + ) + : Deferred.succeed(secondAttempt, undefined).pipe(Effect.asVoid), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* Deferred.await(firstAttempt); + yield* reactor.drain; + yield* Effect.yieldNow; + yield* TestClock.adjust(THREAD_LIFECYCLE_RETRY_DELAY); + yield* Deferred.await(secondAttempt); + yield* reactor.drain; + + expect(yield* Ref.get(archiveAttempts)).toBe(2); + }).pipe(Effect.provide(layer)); + }), +); + effectIt.effect("force-deleting a project removes an already-cold archived thread", () => Effect.gen(function* () { const events = yield* PubSub.unbounded(); @@ -102,6 +298,16 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa const runtimeLayer = ThreadDeletionReactorLive.pipe( Layer.provide(orchestrationEngineLayer), Layer.provide(providerLayer), + Layer.provide( + Layer.mock(ProviderSessionDirectory)({ + getBinding: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ProjectionThreadSessionRepository)({ + getByThreadId: () => Effect.succeed(Option.none()), + }), + ), Layer.provide(terminalLayer), Layer.provide(loggerLayer), Layer.provideMerge(coldStorageLayer), diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7a3a907bdaf..5d9ef5aa5a8 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -3,10 +3,14 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; @@ -22,6 +26,12 @@ type ThreadLifecycleJob = | { readonly type: "delete"; readonly threadId: ThreadDeletedEvent["payload"]["threadId"] } | { readonly type: "compact-legacy-storage" }; +export const THREAD_LIFECYCLE_RETRY_DELAY = "30 seconds"; + +function lifecycleJobKey(job: ThreadLifecycleJob): string { + return job.type === "compact-legacy-storage" ? job.type : `${job.type}:${job.threadId}`; +} + export const logCleanupCauseUnlessInterrupted = ({ effect, message, @@ -46,9 +56,12 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory; + const projectionThreadSessions = yield* ProjectionThreadSessionRepository; const terminalManager = yield* TerminalManager.TerminalManager; const threadColdStorage = yield* ThreadColdStorage; const providerEventLoggers = yield* ProviderEventLoggers; + const retryRequested = yield* Queue.sliding(1); const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ @@ -79,6 +92,30 @@ const make = Effect.gen(function* () { threadId, }); + const stopArchiveProviderSession = Effect.fn("stopArchiveProviderSession")(function* ( + threadId: ThreadArchivedEvent["payload"]["threadId"], + ) { + const binding = yield* providerSessionDirectory.getBinding(threadId); + if (Option.isSome(binding)) { + yield* providerService.stopSession({ threadId }); + return; + } + + const projectedSession = yield* projectionThreadSessions.getByThreadId({ threadId }); + const status = Option.isSome(projectedSession) ? projectedSession.value.status : null; + if (status === "starting" || status === "running") { + // The projection still permits an active writer. Keep failing closed and + // let durable lifecycle recovery retry after the provider binding appears. + yield* providerService.stopSession({ threadId }); + return; + } + + yield* Effect.logDebug("archive cleanup found no provider binding for a settled thread", { + threadId, + projectedSessionStatus: status, + }); + }); + const processLifecycleJob = Effect.fn("processThreadLifecycleJob")(function* ( job: ThreadLifecycleJob, ) { @@ -91,7 +128,7 @@ const make = Effect.gen(function* () { // Archiving must not snapshot or delete hot rows while any active writer // can still mutate them. A failure leaves the durable archived shell or // manifest discoverable so startup recovery can retry the boundary. - yield* providerService.stopSession({ threadId }); + yield* stopArchiveProviderSession(threadId); yield* terminalManager.close({ threadId, deleteHistory: true }); yield* closeProviderLogWritersRequired(threadId); yield* threadColdStorage.archiveThread(threadId); @@ -113,25 +150,30 @@ const make = Effect.gen(function* () { lifecycleAction: job.type, ...(job.type === "compact-legacy-storage" ? {} : { threadId: job.threadId }), cause: Cause.pretty(cause), - }); + }).pipe(Effect.andThen(Queue.offer(retryRequested, undefined)), Effect.asVoid); }), ); - const worker = yield* makeDrainableWorker(processLifecycleJobSafely); + const scheduledJobs = new Set(); + const worker = yield* makeDrainableWorker((job: ThreadLifecycleJob) => + processLifecycleJobSafely(job).pipe( + Effect.ensuring( + Effect.sync(() => { + scheduledJobs.delete(lifecycleJobKey(job)); + }), + ), + ), + ); - const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type === "thread.deleted") { - return worker.enqueue({ type: "delete", threadId: event.payload.threadId }); - } - if (event.type === "thread.archived") { - return worker.enqueue({ type: "archive", threadId: event.payload.threadId }); - } - return Effect.void; - }), - ); + const enqueueLifecycleJob = (job: ThreadLifecycleJob) => + Effect.suspend(() => { + const key = lifecycleJobKey(job); + if (scheduledJobs.has(key)) return Effect.void; + scheduledJobs.add(key); + return worker.enqueue(job); + }); + const enqueuePendingLifecycleJobs = Effect.fn("enqueuePendingThreadLifecycleJobs")(function* () { const pendingJobs = yield* Effect.all([ threadColdStorage.listPendingDeleteThreadIds, threadColdStorage.listPendingArchiveThreadIds, @@ -139,22 +181,46 @@ const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("failed to read pending thread storage migrations", { cause: Cause.pretty(cause), - }).pipe(Effect.as(null)), + }).pipe(Effect.andThen(Queue.offer(retryRequested, undefined)), Effect.as(null)), ), ); if (pendingJobs === null) return; const [pendingDeletes, pendingArchives] = pendingJobs; yield* Effect.forEach( pendingDeletes, - (threadId) => worker.enqueue({ type: "delete", threadId }), + (threadId) => enqueueLifecycleJob({ type: "delete", threadId }), { discard: true }, ); yield* Effect.forEach( pendingArchives, - (threadId) => worker.enqueue({ type: "archive", threadId }), + (threadId) => enqueueLifecycleJob({ type: "archive", threadId }), { discard: true }, ); - yield* worker.enqueue({ type: "compact-legacy-storage" }); + }); + + const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type === "thread.deleted") { + return enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }); + } + if (event.type === "thread.archived") { + return enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }); + } + return Effect.void; + }), + ); + + yield* Queue.take(retryRequested).pipe( + Effect.andThen(Effect.sleep(THREAD_LIFECYCLE_RETRY_DELAY)), + Effect.andThen(enqueuePendingLifecycleJobs()), + Effect.andThen(enqueueLifecycleJob({ type: "compact-legacy-storage" })), + Effect.forever, + Effect.forkScoped, + ); + + yield* enqueuePendingLifecycleJobs(); + yield* enqueueLifecycleJob({ type: "compact-legacy-storage" }); }); return { diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 6d503387bb6..8a54359974d 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { afterEach, vi } from "vite-plus/test"; -import { makeCatalogBackend, makeCatalogStore } from "./storage"; +import { makeCatalogBackend, makeCatalogStore, upgradeConnectionDatabase } from "./storage"; const emptyCatalog = { schemaVersion: 1, @@ -75,3 +75,39 @@ describe("makeCatalogBackend", () => { }), ); }); + +describe("upgradeConnectionDatabase", () => { + it("clears existing thread snapshots when upgrading to archive-time cache eviction", () => { + const stores = new Set(["catalog", "shell", "thread", "server-config", "vcs-refs"]); + const clear = vi.fn(); + const database = { + objectStoreNames: { contains: (name: string) => stores.has(name) }, + createObjectStore: vi.fn((name: string) => stores.add(name)), + } as unknown as IDBDatabase; + const transaction = { + objectStore: vi.fn(() => ({ clear })), + } as unknown as IDBTransaction; + + upgradeConnectionDatabase(database, transaction, 4); + + expect(transaction.objectStore).toHaveBeenCalledWith("thread"); + expect(clear).toHaveBeenCalledOnce(); + }); + + it("does not clear thread snapshots after the migration has already run", () => { + const stores = new Set(["catalog", "shell", "thread", "server-config", "vcs-refs"]); + const clear = vi.fn(); + const database = { + objectStoreNames: { contains: (name: string) => stores.has(name) }, + createObjectStore: vi.fn((name: string) => stores.add(name)), + } as unknown as IDBDatabase; + const transaction = { + objectStore: vi.fn(() => ({ clear })), + } as unknown as IDBTransaction; + + upgradeConnectionDatabase(database, transaction, 5); + + expect(transaction.objectStore).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 04d237a5030..4981a33621e 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -34,7 +34,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; const DATABASE_NAME = "t3code:connection-runtime"; -const DATABASE_VERSION = 4; +const DATABASE_VERSION = 5; const CATALOG_STORE_NAME = "catalog"; const SHELL_STORE_NAME = "shell"; const THREAD_STORE_NAME = "thread"; @@ -42,6 +42,7 @@ const SERVER_CONFIG_STORE_NAME = "server-config"; const VCS_REFS_STORE_NAME = "vcs-refs"; const CATALOG_KEY = "document"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_VERSION = 5; const StoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), @@ -114,6 +115,32 @@ function persistenceError( }); } +export function upgradeConnectionDatabase( + database: IDBDatabase, + transaction: IDBTransaction | null, + oldVersion: number, +) { + if (!database.objectStoreNames.contains(CATALOG_STORE_NAME)) { + database.createObjectStore(CATALOG_STORE_NAME); + } + if (!database.objectStoreNames.contains(SHELL_STORE_NAME)) { + database.createObjectStore(SHELL_STORE_NAME); + } + if (!database.objectStoreNames.contains(THREAD_STORE_NAME)) { + database.createObjectStore(THREAD_STORE_NAME); + } + if (!database.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { + database.createObjectStore(SERVER_CONFIG_STORE_NAME); + } + if (!database.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { + database.createObjectStore(VCS_REFS_STORE_NAME); + } + + if (oldVersion < ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_VERSION) { + transaction?.objectStore(THREAD_STORE_NAME).clear(); + } +} + const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () { return yield* Effect.callback((resume) => { if (typeof indexedDB === "undefined") { @@ -123,22 +150,8 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( return; } const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); - request.addEventListener("upgradeneeded", () => { - if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { - request.result.createObjectStore(CATALOG_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { - request.result.createObjectStore(SHELL_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { - request.result.createObjectStore(THREAD_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { - request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { - request.result.createObjectStore(VCS_REFS_STORE_NAME); - } + request.addEventListener("upgradeneeded", (event) => { + upgradeConnectionDatabase(request.result, request.transaction, event.oldVersion); }); request.addEventListener("error", () => { resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index f3c4c4e6338..ca8d9d6dd59 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -310,6 +310,27 @@ const deleted = (): OrchestrationThreadStreamItem => ({ }, }); +const archived = (): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-archived"), + sequence: 3, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.archived", + payload: { + threadId: THREAD_ID, + archivedAt: "2026-04-01T02:00:00.000Z", + updatedAt: "2026-04-01T02:00:00.000Z", + }, + }, +}); + describe("EnvironmentThreads", () => { it.effect("publishes cached data immediately from a warm cache", () => Effect.gen(function* () { @@ -451,6 +472,38 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("removes cached data when the thread is archived and does not persist it again", () => + Effect.gen(function* () { + const savedThreads = yield* Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, archived()); + + const state = yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.archivedAt !== null, + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(Option.getOrThrow(state.data).archivedAt).toBe("2026-04-01T02:00:00.000Z"); + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + expect( + (yield* Ref.get(harness.savedThreads)).some( + (saved) => saved.thread.archivedAt !== null, + ), + ).toBe(false); + return harness.savedThreads; + }), + ); + + expect((yield* Ref.get(savedThreads)).some((saved) => saved.thread.archivedAt !== null)).toBe( + false, + ); + }), + ); + it.effect("does not resurrect a deleted thread when the app returns to the foreground", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 196229cc8b1..c5c3de98000 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -45,7 +46,7 @@ function formatThreadError(cause: Cause.Cause): string { function shouldPersistThread(thread: OrchestrationThread): boolean { const status = thread.session?.status; - return status !== "starting" && status !== "running"; + return thread.archivedAt === null && status !== "starting" && status !== "running"; } export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( @@ -80,21 +81,33 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); - const persistence = yield* Queue.sliding(1); + const cacheGeneration = yield* Ref.make(0); + const cacheLock = yield* Semaphore.make(1); + const persistence = yield* Queue.sliding<{ + readonly generation: number; + readonly snapshot: OrchestrationThreadDetailSnapshot; + }>(1); - const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( - snapshot: OrchestrationThreadDetailSnapshot, - ) { - yield* cache.saveThread(environmentId, snapshot).pipe( - Effect.catch((error) => - Effect.logWarning("Could not persist the thread cache.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, - }), - ), - ), + const persist = Effect.fn("EnvironmentThreadState.persist")(function* (pending: { + readonly generation: number; + readonly snapshot: OrchestrationThreadDetailSnapshot; + }) { + yield* cacheLock.withPermit( + Effect.gen(function* () { + const currentGeneration = yield* Ref.get(cacheGeneration); + if (currentGeneration !== pending.generation) return; + yield* cache.saveThread(environmentId, pending.snapshot).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }), ); }); @@ -104,6 +117,23 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); + const removeCachedThread = Effect.fn("EnvironmentThreadState.removeCachedThread")(function* () { + yield* Ref.update(cacheGeneration, (generation) => generation + 1); + yield* cacheLock.withPermit( + cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ), + ); + }); + const setSynchronizing = SubscriptionRef.update(state, (current) => current.status === "deleted" ? current @@ -155,7 +185,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - yield* Queue.offer(persistence, { snapshotSequence, thread }); + const generation = yield* Ref.get(cacheGeneration); + yield* Queue.offer(persistence, { + generation, + snapshot: { snapshotSequence, thread }, + }); } }); @@ -166,17 +200,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "deleted", error: Option.none(), }); - yield* cache.removeThread(environmentId, threadId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not remove the cached thread.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, - }), - ), - ), - ); + yield* removeCachedThread(); }); const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( @@ -214,6 +238,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { yield* setThread(result.thread); + if (item.event.type === "thread.archived") { + yield* removeCachedThread(); + } } else if (result.kind === "deleted") { yield* setDeleted(); } @@ -304,7 +331,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(current.data, { onNone: () => Effect.void, onSome: (thread) => - shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void, + shouldPersistThread(thread) + ? Ref.get(cacheGeneration).pipe( + Effect.flatMap((generation) => + persist({ generation, snapshot: { snapshotSequence, thread } }), + ), + ) + : Effect.void, }), ), ), From 559569f5046118ecf3a95ee4a05442af613d4815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 21 Jul 2026 23:54:14 +0100 Subject: [PATCH 29/63] Evict cached thread details after archive reconciliation - Remove persisted details after archive acknowledgement - Evict details for threads removed by authoritative shell snapshots - Keep archive success independent from cache eviction failures - Add focused coverage and update fork documentation --- .../src/state/shell-sync.test.ts | 9 +- packages/client-runtime/src/state/shell.ts | 17 ++ .../client-runtime/src/state/threadCache.ts | 23 +++ .../src/state/threadCommands.test.ts | 149 ++++++++++++++++++ .../src/state/threadCommands.ts | 22 ++- 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 packages/client-runtime/src/state/threadCache.ts create mode 100644 packages/client-runtime/src/state/threadCommands.test.ts diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 60719b6041b..bf744274fce 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -168,6 +168,7 @@ describe("environment shell synchronization", () => { readonly requestCompletionMarker?: boolean; } | null>(null); const loaderCalls = yield* SubscriptionRef.make(0); + const removedThreads = yield* Ref.make([]); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; @@ -195,7 +196,8 @@ describe("environment shell synchronization", () => { saveShell: () => Effect.void, loadThread: () => Effect.succeed(Option.none()), saveThread: () => Effect.void, - removeThread: () => Effect.void, + removeThread: (_environmentId, threadId) => + Ref.update(removedThreads, (threadIds) => [...threadIds, threadId]), loadServerConfig: () => Effect.succeed(Option.none()), saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), @@ -227,6 +229,7 @@ describe("environment shell synchronization", () => { const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(yield* Ref.get(removedThreads)).toEqual(["stale-thread"]); yield* Queue.offer(events, { kind: "snapshot", @@ -245,6 +248,10 @@ describe("environment shell synchronization", () => { expect(Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot).threads).toEqual( [], ); + expect(yield* Ref.get(removedThreads)).toEqual([ + "stale-thread", + "archived-after-http-snapshot", + ]); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 498b7fb8c17..ba541bdb741 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -4,6 +4,7 @@ import { type OrchestrationShellSnapshot, type OrchestrationShellStreamItem, type ServerConfig, + type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -23,6 +24,7 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; +import { evictCachedThread } from "./threadCache.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -160,6 +162,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return; } + const removedThreadIds = Option.match(current.snapshot, { + onNone: () => [] as ReadonlyArray, + onSome: (snapshot) => { + const nextThreadIds = new Set(nextSnapshot.threads.map((thread) => thread.id)); + return snapshot.threads + .map((thread) => thread.id) + .filter((threadId) => !nextThreadIds.has(threadId)); + }, + }); + const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.set(state, { snapshot: Option.some(nextSnapshot), @@ -167,6 +179,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") error: Option.none(), }); yield* Queue.offer(persistence, nextSnapshot); + yield* Effect.forEach( + removedThreadIds, + (threadId) => evictCachedThread(cache, environmentId, threadId), + { discard: true }, + ); }); const foregroundResubscriptions = Option.match(wakeups, { diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts new file mode 100644 index 00000000000..7d4a486d54a --- /dev/null +++ b/packages/client-runtime/src/state/threadCache.ts @@ -0,0 +1,23 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; + +export const evictCachedThread = Effect.fn("EnvironmentThreadCache.evict")(function* ( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +) { + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not evict cached thread detail.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); +}); diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts new file mode 100644 index 00000000000..59cadd53061 --- /dev/null +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -0,0 +1,149 @@ +import { + CommandId, + EnvironmentId, + ORCHESTRATION_WS_METHODS, + ThreadId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import * as RpcSession from "../rpc/session.ts"; +import { archiveThreadAndEvictCache } from "./threadCommands.ts"; + +const TEST_CRYPTO_LAYER = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const TARGET = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const makeSupervisor = Effect.fn("TestThreadCommands.makeSupervisor")(function* ( + client: WsRpcProtocolClient, +) { + const session: RpcSession.RpcSession = { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); +}); + +function makeCache( + removeThread: Persistence.EnvironmentCacheStore["Service"]["removeThread"], +): Persistence.EnvironmentCacheStore["Service"] { + return Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); +} + +describe("thread commands", () => { + it.effect("evicts cached detail after the archive acknowledgement", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const acknowledged = yield* Deferred.make(); + const dispatched = yield* Ref.make([]); + const removals = yield* Ref.make>([]); + const client = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(acknowledged)), + Effect.as({ sequence: 1 }), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = yield* makeSupervisor(client); + const cache = makeCache((environmentId, threadId) => + Ref.update(removals, (entries) => [...entries, [environmentId, threadId] as const]), + ); + + const archive = yield* archiveThreadAndEvictCache({ + commandId: CommandId.make("archive-command"), + threadId: THREAD_ID, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(started); + expect(yield* Ref.get(removals)).toEqual([]); + + yield* Deferred.succeed(acknowledged, undefined); + expect(yield* Fiber.join(archive)).toEqual({ sequence: 1 }); + expect(yield* Ref.get(removals)).toEqual([[ENVIRONMENT_ID, THREAD_ID]]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("keeps a successful archive successful when cache eviction fails", () => + Effect.gen(function* () { + const client = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (_command: ClientOrchestrationCommand) => + Effect.succeed({ sequence: 1 }), + } as unknown as WsRpcProtocolClient; + const supervisor = yield* makeSupervisor(client); + const cache = makeCache(() => + Effect.fail( + new Persistence.ConnectionPersistenceError({ + operation: "remove-thread", + message: "IndexedDB unavailable", + }), + ), + ); + + const result = yield* archiveThreadAndEvictCache({ + commandId: CommandId.make("archive-command"), + threadId: THREAD_ID, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + + expect(result).toEqual({ sequence: 1 }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..5da184ad484 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,7 +1,11 @@ import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts"; +import { evictCachedThread } from "./threadCache.ts"; import { type ArchiveThreadInput, type CreateThreadInput, @@ -48,8 +52,22 @@ export type { UpdateThreadMetadataInput, } from "../operations/commands.ts"; +export const archiveThreadAndEvictCache = Effect.fn( + "EnvironmentCommands.archiveThreadAndEvictCache", +)(function* (input: ArchiveThreadInput) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const result = yield* restore(archiveThread(input)); + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + yield* evictCachedThread(cache, supervisor.target.environmentId, input.threadId); + return result; + }), + ); +}); + export function createThreadEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const scheduler = createAtomCommandScheduler(); const concurrency = { @@ -72,7 +90,7 @@ export function createThreadEnvironmentAtoms( }), archive: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:archive", - execute: (input: ArchiveThreadInput) => archiveThread(input), + execute: (input: ArchiveThreadInput) => archiveThreadAndEvictCache(input), scheduler, concurrency, }), From 20c31e56d7ebd52dceaa08d261dcf925e53b1e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 00:10:51 +0100 Subject: [PATCH 30/63] Prevent archived thread cache restoration - Share per-thread cache generations, tombstones, and write locks - Re-enable cache writes after authoritative thread restoration - Cover queued and teardown writes after out-of-band eviction --- packages/client-runtime/src/state/shell.ts | 16 ++- .../client-runtime/src/state/threadCache.ts | 112 ++++++++++++++++-- .../src/state/threads-sync.test.ts | 73 ++++++++++++ packages/client-runtime/src/state/threads.ts | 56 +++------ 4 files changed, 206 insertions(+), 51 deletions(-) diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index ba541bdb741..9ad034c4354 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -24,7 +24,7 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; -import { evictCachedThread } from "./threadCache.ts"; +import { evictCachedThread, reviveCachedThread } from "./threadCache.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -171,6 +171,15 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") .filter((threadId) => !nextThreadIds.has(threadId)); }, }); + const addedThreadIds = Option.match(current.snapshot, { + onNone: () => nextSnapshot.threads.map((thread) => thread.id), + onSome: (snapshot) => { + const currentThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); + return nextSnapshot.threads + .map((thread) => thread.id) + .filter((threadId) => !currentThreadIds.has(threadId)); + }, + }); const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.set(state, { @@ -184,6 +193,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") (threadId) => evictCachedThread(cache, environmentId, threadId), { discard: true }, ); + yield* Effect.forEach( + addedThreadIds, + (threadId) => reviveCachedThread(cache, environmentId, threadId), + { discard: true }, + ); }); const foregroundResubscriptions = Option.match(wakeups, { diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts index 7d4a486d54a..53226ce2c79 100644 --- a/packages/client-runtime/src/state/threadCache.ts +++ b/packages/client-runtime/src/state/threadCache.ts @@ -1,23 +1,115 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; +interface ThreadCacheState { + generation: number; + evicted: boolean; + readonly lock: Semaphore.Semaphore; +} + +const cacheStates = new WeakMap>(); + +function threadCacheState( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +): ThreadCacheState { + let entries = cacheStates.get(cache); + if (entries === undefined) { + entries = new Map(); + cacheStates.set(cache, entries); + } + + const key = JSON.stringify([environmentId, threadId]); + const existing = entries.get(key); + if (existing !== undefined) { + return existing; + } + + const created: ThreadCacheState = { + generation: 0, + evicted: false, + lock: Semaphore.makeUnsafe(1), + }; + entries.set(key, created); + return created; +} + +export function cachedThreadGeneration( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +): number { + return threadCacheState(cache, environmentId, threadId).generation; +} + +export const persistCachedThread = Effect.fn("EnvironmentThreadCache.persist")(function* ( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + snapshot: Parameters[1], + generation: number, +) { + const threadId = snapshot.thread.id; + const state = threadCacheState(cache, environmentId, threadId); + yield* state.lock.withPermit( + Effect.gen(function* () { + if (state.evicted || state.generation !== generation) { + return; + } + yield* cache.saveThread(environmentId, snapshot).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + }), + ); +}); + export const evictCachedThread = Effect.fn("EnvironmentThreadCache.evict")(function* ( cache: EnvironmentCacheStore["Service"], environmentId: EnvironmentId, threadId: ThreadId, ) { - yield* cache.removeThread(environmentId, threadId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not evict cached thread detail.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - ...safeErrorLogAttributes(error), - }), - ), - ), + const state = threadCacheState(cache, environmentId, threadId); + yield* state.lock.withPermit( + Effect.gen(function* () { + state.generation += 1; + state.evicted = true; + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not evict cached thread detail.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + }), + ); +}); + +export const reviveCachedThread = Effect.fn("EnvironmentThreadCache.revive")(function* ( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +) { + const state = threadCacheState(cache, environmentId, threadId); + yield* state.lock.withPermit( + Effect.sync(() => { + state.generation += 1; + state.evicted = false; + }), ); }); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index ca8d9d6dd59..1a21060b156 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -36,6 +36,7 @@ import { ThreadSnapshotLoader, type EnvironmentThreadState, } from "./threads.ts"; +import { evictCachedThread } from "./threadCache.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -246,6 +247,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o supervisorSession, savedThreads, removedThreads, + cache, wakeups, replaceSession: SubscriptionRef.set( supervisorSession, @@ -331,6 +333,26 @@ const archived = (): OrchestrationThreadStreamItem => ({ }, }); +const unarchived = (): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-unarchived"), + sequence: 4, + occurredAt: "2026-04-01T03:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.unarchived", + payload: { + threadId: THREAD_ID, + updatedAt: "2026-04-01T03:00:00.000Z", + }, + }, +}); + describe("EnvironmentThreads", () => { it.effect("publishes cached data immediately from a warm cache", () => Effect.gen(function* () { @@ -504,6 +526,57 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("does not restore an out-of-band cache eviction from queued or teardown writes", () => + Effect.gen(function* () { + const savedThreads = yield* Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer( + harness.inputs, + titleUpdated("Stale pending title", CACHED_SNAPSHOT_SEQUENCE + 1), + ); + yield* awaitThreadState( + harness.observed, + (value) => + Option.isSome(value.data) && value.data.value.title === "Stale pending title", + ); + + yield* evictCachedThread(harness.cache, TARGET.environmentId, THREAD_ID); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + expect(yield* Ref.get(harness.savedThreads)).toEqual([]); + return harness.savedThreads; + }), + ); + + expect(yield* Ref.get(savedThreads)).toEqual([]); + }), + ); + + it.effect("persists thread detail again after an authoritative unarchive event", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, archived()); + yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.archivedAt !== null, + ); + + yield* Queue.offer(harness.inputs, unarchived()); + yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.archivedAt === null, + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.thread.archivedAt).toBeNull(); + }), + ); + it.effect("does not resurrect a deleted thread when the app returns to the foreground", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index c5c3de98000..e5c001ac898 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -11,7 +11,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -23,6 +22,12 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { + cachedThreadGeneration, + evictCachedThread, + persistCachedThread, + reviveCachedThread, +} from "./threadCache.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; @@ -81,8 +86,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); - const cacheGeneration = yield* Ref.make(0); - const cacheLock = yield* Semaphore.make(1); const persistence = yield* Queue.sliding<{ readonly generation: number; readonly snapshot: OrchestrationThreadDetailSnapshot; @@ -92,23 +95,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make readonly generation: number; readonly snapshot: OrchestrationThreadDetailSnapshot; }) { - yield* cacheLock.withPermit( - Effect.gen(function* () { - const currentGeneration = yield* Ref.get(cacheGeneration); - if (currentGeneration !== pending.generation) return; - yield* cache.saveThread(environmentId, pending.snapshot).pipe( - Effect.catch((error) => - Effect.logWarning("Could not persist the thread cache.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, - }), - ), - ), - ); - }), - ); + yield* persistCachedThread(cache, environmentId, pending.snapshot, pending.generation); }); yield* Stream.fromQueue(persistence).pipe( @@ -118,20 +105,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const removeCachedThread = Effect.fn("EnvironmentThreadState.removeCachedThread")(function* () { - yield* Ref.update(cacheGeneration, (generation) => generation + 1); - yield* cacheLock.withPermit( - cache.removeThread(environmentId, threadId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not remove the cached thread.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, - }), - ), - ), - ), - ); + yield* evictCachedThread(cache, environmentId, threadId); }); const setSynchronizing = SubscriptionRef.update(state, (current) => @@ -185,7 +159,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - const generation = yield* Ref.get(cacheGeneration); + const generation = cachedThreadGeneration(cache, environmentId, threadId); yield* Queue.offer(persistence, { generation, snapshot: { snapshotSequence, thread }, @@ -237,6 +211,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { + if (item.event.type === "thread.unarchived") { + yield* reviveCachedThread(cache, environmentId, threadId); + } yield* setThread(result.thread); if (item.event.type === "thread.archived") { yield* removeCachedThread(); @@ -332,11 +309,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onNone: () => Effect.void, onSome: (thread) => shouldPersistThread(thread) - ? Ref.get(cacheGeneration).pipe( - Effect.flatMap((generation) => - persist({ generation, snapshot: { snapshotSequence, thread } }), - ), - ) + ? persist({ + generation: cachedThreadGeneration(cache, environmentId, threadId), + snapshot: { snapshotSequence, thread }, + }) : Effect.void, }), ), From c6b4c6462265ce3f2b8deda3b611093e4662ed7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 00:20:15 +0100 Subject: [PATCH 31/63] Fix archive cache revival lifecycle - Guard revival generations and prune retained cache state - Evict shell removals before publishing reconciled snapshots - Cover shell additions and stale cache writes --- .../src/state/shell-sync.test.ts | 30 ++- packages/client-runtime/src/state/shell.ts | 44 +++-- .../client-runtime/src/state/threadCache.ts | 183 +++++++++++++----- .../src/state/threadCommands.ts | 2 + .../src/state/threads-sync.test.ts | 62 +++++- packages/client-runtime/src/state/threads.ts | 4 + 6 files changed, 255 insertions(+), 70 deletions(-) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index bf744274fce..b4abed25b48 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, ORCHESTRATION_WS_METHODS, + ThreadId, type OrchestrationShellSnapshot, type OrchestrationShellStreamItem, } from "@t3tools/contracts"; @@ -23,6 +24,12 @@ import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts"; +import { + cachedThreadGeneration, + evictCachedThread, + persistCachedThread, + retainCachedThread, +} from "./threadCache.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -169,6 +176,8 @@ describe("environment shell synchronization", () => { } | null>(null); const loaderCalls = yield* SubscriptionRef.make(0); const removedThreads = yield* Ref.make([]); + const savedThreads = yield* Ref.make([]); + const addedThreadId = ThreadId.make("archived-after-http-snapshot"); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; @@ -195,7 +204,8 @@ describe("environment shell synchronization", () => { loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), saveShell: () => Effect.void, loadThread: () => Effect.succeed(Option.none()), - saveThread: () => Effect.void, + saveThread: (_environmentId, snapshot) => + Ref.update(savedThreads, (threadIds) => [...threadIds, snapshot.thread.id]), removeThread: (_environmentId, threadId) => Ref.update(removedThreads, (threadIds) => [...threadIds, threadId]), loadServerConfig: () => Effect.succeed(Option.none()), @@ -204,6 +214,10 @@ describe("environment shell synchronization", () => { saveVcsRefs: () => Effect.void, clear: () => Effect.void, }); + yield* retainCachedThread(cache, TARGET.environmentId, addedThreadId); + const staleGeneration = cachedThreadGeneration(cache, TARGET.environmentId, addedThreadId); + yield* evictCachedThread(cache, TARGET.environmentId, addedThreadId); + yield* Ref.set(removedThreads, []); const snapshotLoader = ShellSnapshotLoader.of({ load: () => SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( @@ -230,6 +244,20 @@ describe("environment shell synchronization", () => { expect(synchronizing.status).toBe("synchronizing"); expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); expect(yield* Ref.get(removedThreads)).toEqual(["stale-thread"]); + yield* persistCachedThread( + cache, + TARGET.environmentId, + { snapshotSequence: 9, thread: { id: addedThreadId } as never }, + staleGeneration, + ); + expect(yield* Ref.get(savedThreads)).toEqual([]); + yield* persistCachedThread( + cache, + TARGET.environmentId, + { snapshotSequence: 9, thread: { id: addedThreadId } as never }, + cachedThreadGeneration(cache, TARGET.environmentId, addedThreadId), + ); + expect(yield* Ref.get(savedThreads)).toEqual([addedThreadId]); yield* Queue.offer(events, { kind: "snapshot", diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 9ad034c4354..e18a9589f4b 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -162,32 +162,28 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return; } + const nextThreadIds = new Set(nextSnapshot.threads.map((thread) => thread.id)); + const currentThreadIds = Option.match(current.snapshot, { + onNone: () => new Set(), + onSome: (snapshot) => new Set(snapshot.threads.map((thread) => thread.id)), + }); const removedThreadIds = Option.match(current.snapshot, { onNone: () => [] as ReadonlyArray, - onSome: (snapshot) => { - const nextThreadIds = new Set(nextSnapshot.threads.map((thread) => thread.id)); - return snapshot.threads - .map((thread) => thread.id) - .filter((threadId) => !nextThreadIds.has(threadId)); - }, + onSome: (snapshot) => + snapshot.threads + .filter((thread) => !nextThreadIds.has(thread.id)) + .map((thread) => thread.id), }); const addedThreadIds = Option.match(current.snapshot, { - onNone: () => nextSnapshot.threads.map((thread) => thread.id), - onSome: (snapshot) => { - const currentThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); - return nextSnapshot.threads - .map((thread) => thread.id) - .filter((threadId) => !currentThreadIds.has(threadId)); - }, + onNone: () => [] as ReadonlyArray, + onSome: () => + nextSnapshot.threads + .filter((thread) => !currentThreadIds.has(thread.id)) + .map((thread) => thread.id), }); - const waiting = yield* Ref.get(awaitingCompletion); - yield* SubscriptionRef.set(state, { - snapshot: Option.some(nextSnapshot), - status: waiting ? "synchronizing" : "live", - error: Option.none(), - }); - yield* Queue.offer(persistence, nextSnapshot); + // Advance cache tombstones before publishing the new shell so detail + // observers cannot enqueue an obsolete write in the transition window. yield* Effect.forEach( removedThreadIds, (threadId) => evictCachedThread(cache, environmentId, threadId), @@ -198,6 +194,14 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") (threadId) => reviveCachedThread(cache, environmentId, threadId), { discard: true }, ); + + const waiting = yield* Ref.get(awaitingCompletion); + yield* SubscriptionRef.set(state, { + snapshot: Option.some(nextSnapshot), + status: waiting ? "synchronizing" : "live", + error: Option.none(), + }); + yield* Queue.offer(persistence, nextSnapshot); }); const foregroundResubscriptions = Option.match(wakeups, { diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts index 53226ce2c79..cf58e0befad 100644 --- a/packages/client-runtime/src/state/threadCache.ts +++ b/packages/client-runtime/src/state/threadCache.ts @@ -8,24 +8,42 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; interface ThreadCacheState { generation: number; evicted: boolean; + retainers: number; + operations: number; readonly lock: Semaphore.Semaphore; } -const cacheStates = new WeakMap>(); +type EnvironmentThreadCacheStates = Map; +type CacheStates = Map; + +const cacheStates = new WeakMap(); + +function existingThreadCacheState( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +): ThreadCacheState | undefined { + return cacheStates.get(cache)?.get(environmentId)?.get(threadId); +} function threadCacheState( cache: EnvironmentCacheStore["Service"], environmentId: EnvironmentId, threadId: ThreadId, ): ThreadCacheState { - let entries = cacheStates.get(cache); - if (entries === undefined) { - entries = new Map(); - cacheStates.set(cache, entries); + let environmentEntries = cacheStates.get(cache); + if (environmentEntries === undefined) { + environmentEntries = new Map(); + cacheStates.set(cache, environmentEntries); + } + + let threadEntries = environmentEntries.get(environmentId); + if (threadEntries === undefined) { + threadEntries = new Map(); + environmentEntries.set(environmentId, threadEntries); } - const key = JSON.stringify([environmentId, threadId]); - const existing = entries.get(key); + const existing = threadEntries.get(threadId); if (existing !== undefined) { return existing; } @@ -33,18 +51,81 @@ function threadCacheState( const created: ThreadCacheState = { generation: 0, evicted: false, + retainers: 0, + operations: 0, lock: Semaphore.makeUnsafe(1), }; - entries.set(key, created); + threadEntries.set(threadId, created); return created; } +function pruneThreadCacheState( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, + state: ThreadCacheState, +): void { + if (state.retainers > 0 || state.operations > 0) return; + + const environmentEntries = cacheStates.get(cache); + const threadEntries = environmentEntries?.get(environmentId); + if (threadEntries?.get(threadId) !== state) return; + + threadEntries.delete(threadId); + if (threadEntries.size === 0) { + environmentEntries?.delete(environmentId); + } + if (environmentEntries?.size === 0) { + cacheStates.delete(cache); + } +} + +function withThreadCacheState( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, + use: (state: ThreadCacheState) => Effect.Effect, +): Effect.Effect { + return Effect.acquireUseRelease( + Effect.sync(() => { + const state = threadCacheState(cache, environmentId, threadId); + state.operations += 1; + return state; + }), + use, + (state) => + Effect.sync(() => { + state.operations -= 1; + pruneThreadCacheState(cache, environmentId, threadId, state); + }), + ); +} + +export const retainCachedThread = Effect.fn("EnvironmentThreadCache.retain")(function* ( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +) { + yield* Effect.acquireRelease( + Effect.sync(() => { + const state = threadCacheState(cache, environmentId, threadId); + state.retainers += 1; + return state; + }), + (state) => + Effect.sync(() => { + state.retainers -= 1; + pruneThreadCacheState(cache, environmentId, threadId, state); + }), + ); +}); + export function cachedThreadGeneration( cache: EnvironmentCacheStore["Service"], environmentId: EnvironmentId, threadId: ThreadId, ): number { - return threadCacheState(cache, environmentId, threadId).generation; + return existingThreadCacheState(cache, environmentId, threadId)?.generation ?? 0; } export const persistCachedThread = Effect.fn("EnvironmentThreadCache.persist")(function* ( @@ -54,24 +135,27 @@ export const persistCachedThread = Effect.fn("EnvironmentThreadCache.persist")(f generation: number, ) { const threadId = snapshot.thread.id; - const state = threadCacheState(cache, environmentId, threadId); - yield* state.lock.withPermit( - Effect.gen(function* () { - if (state.evicted || state.generation !== generation) { - return; - } - yield* cache.saveThread(environmentId, snapshot).pipe( - Effect.catch((error) => - Effect.logWarning("Could not persist the thread cache.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - ...safeErrorLogAttributes(error), - }), + yield* withThreadCacheState(cache, environmentId, threadId, (state) => + // Keep persistence under the same permit as eviction. Otherwise an older + // write can pass its generation check and finish after cache removal. + state.lock.withPermit( + Effect.gen(function* () { + if (state.evicted || state.generation !== generation) { + return; + } + yield* cache.saveThread(environmentId, snapshot).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + ...safeErrorLogAttributes(error), + }), + ), ), - ), - ); - }), + ); + }), + ), ); }); @@ -80,23 +164,24 @@ export const evictCachedThread = Effect.fn("EnvironmentThreadCache.evict")(funct environmentId: EnvironmentId, threadId: ThreadId, ) { - const state = threadCacheState(cache, environmentId, threadId); - yield* state.lock.withPermit( - Effect.gen(function* () { - state.generation += 1; - state.evicted = true; - yield* cache.removeThread(environmentId, threadId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not evict cached thread detail.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - ...safeErrorLogAttributes(error), - }), + yield* withThreadCacheState(cache, environmentId, threadId, (state) => + state.lock.withPermit( + Effect.gen(function* () { + state.generation += 1; + state.evicted = true; + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not evict cached thread detail.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + ...safeErrorLogAttributes(error), + }), + ), ), - ), - ); - }), + ); + }), + ), ); }); @@ -105,11 +190,13 @@ export const reviveCachedThread = Effect.fn("EnvironmentThreadCache.revive")(fun environmentId: EnvironmentId, threadId: ThreadId, ) { - const state = threadCacheState(cache, environmentId, threadId); - yield* state.lock.withPermit( - Effect.sync(() => { - state.generation += 1; - state.evicted = false; - }), + yield* withThreadCacheState(cache, environmentId, threadId, (state) => + state.lock.withPermit( + Effect.sync(() => { + if (state.evicted) { + state.evicted = false; + } + }), + ), ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 5da184ad484..e30e25ccce3 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -60,6 +60,8 @@ export const archiveThreadAndEvictCache = Effect.fn( const result = yield* restore(archiveThread(input)); const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; + // The shell/detail event paths also evict. This acknowledgement-side + // eviction closes the route-teardown race when those events arrive late. yield* evictCachedThread(cache, supervisor.target.environmentId, input.threadId); return result; }), diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 1a21060b156..7e4a95ce4f5 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -36,7 +36,12 @@ import { ThreadSnapshotLoader, type EnvironmentThreadState, } from "./threads.ts"; -import { evictCachedThread } from "./threadCache.ts"; +import { + cachedThreadGeneration, + evictCachedThread, + persistCachedThread, + reviveCachedThread, +} from "./threadCache.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -555,6 +560,61 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("keeps an active cache write valid when revival is already satisfied", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + const generation = cachedThreadGeneration(harness.cache, TARGET.environmentId, THREAD_ID); + + yield* reviveCachedThread(harness.cache, TARGET.environmentId, THREAD_ID); + yield* persistCachedThread( + harness.cache, + TARGET.environmentId, + { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, thread: BASE_THREAD }, + generation, + ); + + expect(yield* Ref.get(harness.savedThreads)).toEqual([ + { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, thread: BASE_THREAD }, + ]); + }), + ); + + it.effect("rejects a pre-eviction write after cache revival", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + const staleGeneration = cachedThreadGeneration( + harness.cache, + TARGET.environmentId, + THREAD_ID, + ); + + yield* evictCachedThread(harness.cache, TARGET.environmentId, THREAD_ID); + yield* reviveCachedThread(harness.cache, TARGET.environmentId, THREAD_ID); + yield* persistCachedThread( + harness.cache, + TARGET.environmentId, + { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, thread: BASE_THREAD }, + staleGeneration, + ); + expect(yield* Ref.get(harness.savedThreads)).toEqual([]); + + const revivedGeneration = cachedThreadGeneration( + harness.cache, + TARGET.environmentId, + THREAD_ID, + ); + yield* persistCachedThread( + harness.cache, + TARGET.environmentId, + { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, thread: BASE_THREAD }, + revivedGeneration, + ); + expect(yield* Ref.get(harness.savedThreads)).toEqual([ + { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, thread: BASE_THREAD }, + ]); + }), + ); + it.effect("persists thread detail again after an authoritative unarchive event", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index e5c001ac898..cd316b35dd0 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -26,6 +26,7 @@ import { cachedThreadGeneration, evictCachedThread, persistCachedThread, + retainCachedThread, reviveCachedThread, } from "./threadCache.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; @@ -62,6 +63,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; + // Keep the cross-surface cache generation alive until this detail state's + // persistence worker and teardown write have both finalized. + yield* retainCachedThread(cache, environmentId, threadId); const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( From 299048ecb06ee1dca40a7ee7be219c72705795c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 00:38:08 +0100 Subject: [PATCH 32/63] Invalidate tombstoned cache writes on revival - Advance the generation only when clearing an eviction tombstone - Cover writes captured during eviction with a regression test --- .../client-runtime/src/state/threadCache.ts | 3 +++ .../src/state/threads-sync.test.ts | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts index cf58e0befad..a5c8b57f5f0 100644 --- a/packages/client-runtime/src/state/threadCache.ts +++ b/packages/client-runtime/src/state/threadCache.ts @@ -194,6 +194,9 @@ export const reviveCachedThread = Effect.fn("EnvironmentThreadCache.revive")(fun state.lock.withPermit( Effect.sync(() => { if (state.evicted) { + // Invalidate writes that captured the eviction generation while the + // tombstone was active before making the cache writable again. + state.generation += 1; state.evicted = false; } }), diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 7e4a95ce4f5..d7aefcccbfb 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -615,6 +615,29 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("rejects a write captured while the cache is evicted after revival", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + + yield* evictCachedThread(harness.cache, TARGET.environmentId, THREAD_ID); + const evictedGeneration = cachedThreadGeneration( + harness.cache, + TARGET.environmentId, + THREAD_ID, + ); + + yield* reviveCachedThread(harness.cache, TARGET.environmentId, THREAD_ID); + yield* persistCachedThread( + harness.cache, + TARGET.environmentId, + { snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, thread: BASE_THREAD }, + evictedGeneration, + ); + + expect(yield* Ref.get(harness.savedThreads)).toEqual([]); + }), + ); + it.effect("persists thread detail again after an authoritative unarchive event", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); From 57ffb32f178b3845eebc719e53092f6468fd2472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 10:21:45 +0100 Subject: [PATCH 33/63] Strengthen archived thread cache assertions - Reject all saved snapshots after archive eviction --- packages/client-runtime/src/state/threads-sync.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index d7aefcccbfb..29298418da9 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -516,18 +516,12 @@ describe("EnvironmentThreads", () => { expect(Option.getOrThrow(state.data).archivedAt).toBe("2026-04-01T02:00:00.000Z"); expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); - expect( - (yield* Ref.get(harness.savedThreads)).some( - (saved) => saved.thread.archivedAt !== null, - ), - ).toBe(false); + expect(yield* Ref.get(harness.savedThreads)).toEqual([]); return harness.savedThreads; }), ); - expect((yield* Ref.get(savedThreads)).some((saved) => saved.thread.archivedAt !== null)).toBe( - false, - ); + expect(yield* Ref.get(savedThreads)).toEqual([]); }), ); From 1a94992eceb22a1de65dc54b3c7dda52eae56762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 10:36:33 +0100 Subject: [PATCH 34/63] Clear legacy mobile thread caches during migration - Bump the mobile client database schema to version 2 - Delete persisted thread details without clearing other cache kinds - Cover the version 1 upgrade and document both client migrations --- apps/mobile/src/lib/storage.test.ts | 2 +- .../src/persistence/mobile-database.test.ts | 33 ++++++++++++++++++- .../mobile/src/persistence/mobile-database.ts | 21 ++++++++---- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a97252c7b72..22fcb8ff789 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -43,7 +43,7 @@ const mocks = vi.hoisted(() => { ), getFirstAsync: vi.fn((sql: string) => { if (sql.includes("PRAGMA user_version")) { - return Promise.resolve({ user_version: 1 }); + return Promise.resolve({ user_version: 2 }); } if (loadPreferencesFails) { return Promise.reject(new Error("database unavailable")); diff --git a/apps/mobile/src/persistence/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts index 56c3e31657e..a15a1c18c0f 100644 --- a/apps/mobile/src/persistence/mobile-database.test.ts +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { vi } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; const openDatabaseAsync = vi.hoisted(() => vi.fn()); @@ -9,6 +9,10 @@ vi.mock("expo-sqlite", () => ({ openDatabaseAsync })); import { decodeLegacyCacheRecord, make } from "./mobile-database"; describe("mobile database legacy cache migration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it.effect("keeps acquisition failures typed on database operations", () => Effect.scoped( Effect.gen(function* () { @@ -68,4 +72,31 @@ describe("mobile database legacy cache migration", () => { ), ).toBeNull(); }); + + it.effect("clears persisted thread details when upgrading from schema version 1", () => + Effect.scoped( + Effect.gen(function* () { + const transactionExecAsync = vi.fn(() => Promise.resolve()); + const transactionRunAsync = vi.fn(() => Promise.resolve()); + const database = { + closeAsync: vi.fn(() => Promise.resolve()), + execAsync: vi.fn(() => Promise.resolve()), + getFirstAsync: vi.fn(() => Promise.resolve({ user_version: 1 })), + withExclusiveTransactionAsync: vi.fn((run: (transaction: unknown) => Promise) => + run({ execAsync: transactionExecAsync, runAsync: transactionRunAsync }), + ), + }; + openDatabaseAsync.mockResolvedValueOnce(database); + + yield* make; + + expect(transactionRunAsync).toHaveBeenCalledOnce(); + expect(transactionRunAsync).toHaveBeenCalledWith( + "DELETE FROM client_cache WHERE kind = ?", + "thread", + ); + expect(transactionExecAsync).toHaveBeenCalledWith("PRAGMA user_version = 2;"); + }), + ), + ); }); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 33eb50f640c..7248e412b01 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -7,7 +7,9 @@ import * as Schema from "effect/Schema"; import type { SQLiteDatabase } from "expo-sqlite"; const DATABASE_NAME = "t3code-client.db"; -const DATABASE_SCHEMA_VERSION = 1; +const LEGACY_FILE_CACHE_DATABASE_SCHEMA_VERSION = 1; +const ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_SCHEMA_VERSION = 2; +const DATABASE_SCHEMA_VERSION = ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_SCHEMA_VERSION; const LEGACY_CACHE_DIRECTORIES = [ "connection-shell-snapshots", "shell-snapshots", @@ -262,11 +264,18 @@ const makeAvailable = Effect.gen(function* () { ); `); }); - if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) { - const migrated = await migrateLegacyFileCaches(database); - if (migrated) { - await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`); - } + const previousSchemaVersion = schema?.user_version ?? 0; + const legacyCachesMigrated = + previousSchemaVersion < LEGACY_FILE_CACHE_DATABASE_SCHEMA_VERSION + ? await migrateLegacyFileCaches(database) + : true; + if (legacyCachesMigrated && previousSchemaVersion < DATABASE_SCHEMA_VERSION) { + await database.withExclusiveTransactionAsync(async (transaction) => { + if (previousSchemaVersion < ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_SCHEMA_VERSION) { + await transaction.runAsync("DELETE FROM client_cache WHERE kind = ?", "thread"); + } + await transaction.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`); + }); } }, catch: databaseError("migrate"), From 6422de5250da83e786b4e1cfa8a04313726c408d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 10:54:45 +0100 Subject: [PATCH 35/63] Harden archive cleanup retry and cache eviction - Release lifecycle reservations when enqueueing does not complete - Coalesce retry bursts and cover successful retry quiescence - Evict archived snapshots without redundant first-open cache clears --- BRANCH_DETAILS.md | 6 +- .../Layers/ThreadDeletionReactor.test.ts | 71 +++++++++++++++++++ .../Layers/ThreadDeletionReactor.ts | 28 ++++++-- apps/web/src/connection/storage.test.ts | 18 +++++ apps/web/src/connection/storage.ts | 2 +- .../src/state/threads-sync.test.ts | 22 ++++++ packages/client-runtime/src/state/threads.ts | 3 + 7 files changed, 141 insertions(+), 9 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 09c932f697c..f3fc67cd5a2 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -8,7 +8,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. - Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. -- Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction, require provider/terminal/log-writer shutdown to succeed, and keep retry state for filesystem failures other than a genuinely missing directory. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. +- Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. - A `restored` manifest protects only the archive epoch that an unarchive command reserved. If post-commit bundle finalization fails and the user later archives the thread again, the newer shell timestamp lets lifecycle work replace the stale manifest and move the conversation cold. @@ -31,7 +31,9 @@ Sidebar archive visibility is centralized across persisted and optimistic archiv Authoritative shell synchronization is shared runtime behavior in `packages/client-runtime/src/state/shell.ts`, `packages/client-runtime/src/rpc/client.ts`, and `apps/server/src/ws.ts`, rather than a branch-specific subscription implementation. HTTP snapshots provide an early shell, while completion-capable WebSocket sessions revalidate it with a socket-owned snapshot after live buffering begins; the same refresh runs when the app returns to the foreground. Cold archive storage relies on this contract because compacted per-thread history cannot prove that cached projects and threads still exist. -`apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. +Persisted web and mobile thread details are also fast-paint caches, not an archive source of truth. A `thread.archived` detail event, an authoritative archived detail snapshot, successful local archive acknowledgement, or shell removal evicts the persisted detail through `packages/client-runtime/src/state/threadCache.ts`. Per-thread cache generations, eviction tombstones, and write locks prevent queued or idle-finalizer writes from recreating the snapshot. Shell additions and `thread.unarchived` events revive the cache while invalidating only writes captured during the tombstoned generation. Web IndexedDB migration version 5 and mobile SQLite migration version 2 each clear legacy thread-detail caches once; active details repopulate on demand. + +`apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived. General mobile time rendering is unchanged. diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 459fa546207..1562da40270 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -40,6 +40,7 @@ import { ThreadColdStorage, ThreadColdStorageError } from "../Services/ThreadCol import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; import { + enqueueLifecycleJobOnce, logCleanupCauseUnlessInterrupted, THREAD_LIFECYCLE_RETRY_DELAY, ThreadDeletionReactorLive, @@ -140,6 +141,26 @@ describe("logCleanupCauseUnlessInterrupted", () => { }); }); +effectIt.effect("releases a lifecycle job reservation when enqueueing is interrupted", () => + Effect.gen(function* () { + const scheduledJobs = new Set(); + const enqueueCalls = yield* Ref.make(0); + + const interrupted = yield* Effect.exit( + enqueueLifecycleJobOnce(scheduledJobs, "archive:thread-interrupted", Effect.interrupt), + ); + expect(Exit.isFailure(interrupted)).toBe(true); + expect(scheduledJobs.has("archive:thread-interrupted")).toBe(false); + + yield* enqueueLifecycleJobOnce( + scheduledJobs, + "archive:thread-interrupted", + Ref.update(enqueueCalls, (count) => count + 1), + ); + expect(yield* Ref.get(enqueueCalls)).toBe(1); + }), +); + effectIt.effect("archives a settled thread when its provider binding is already absent", () => Effect.gen(function* () { const events = yield* PubSub.unbounded(); @@ -265,6 +286,56 @@ effectIt.effect("retries a failed durable archive job after a delay", () => }), ); +effectIt.effect("coalesces concurrent lifecycle failures into one delayed rescan", () => + Effect.gen(function* () { + const firstThreadId = ThreadId.make("thread-archive-retry-first"); + const secondThreadId = ThreadId.make("thread-archive-retry-second"); + const retryCompleted = yield* Deferred.make(); + const archiveAttempts = yield* Ref.make(0); + const layer = testReactorLayer({ + eventStream: Stream.empty, + stopSession: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + getProjectedSession: () => Effect.succeed(Option.none()), + pendingArchives: [firstThreadId, secondThreadId], + archiveThread: (threadId) => + Ref.updateAndGet(archiveAttempts, (count) => count + 1).pipe( + Effect.flatMap((attempt) => { + if (attempt <= 2) { + return Effect.fail( + new ThreadColdStorageError({ + operation: "archive", + threadId, + cause: new Error("temporary archive failure"), + }), + ); + } + return attempt === 4 + ? Deferred.succeed(retryCompleted, undefined).pipe(Effect.asVoid) + : Effect.void; + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* reactor.drain; + expect(yield* Ref.get(archiveAttempts)).toBe(2); + + yield* TestClock.adjust(THREAD_LIFECYCLE_RETRY_DELAY); + yield* Deferred.await(retryCompleted); + yield* reactor.drain; + expect(yield* Ref.get(archiveAttempts)).toBe(4); + + yield* TestClock.adjust(THREAD_LIFECYCLE_RETRY_DELAY); + yield* Effect.yieldNow; + yield* reactor.drain; + expect(yield* Ref.get(archiveAttempts)).toBe(4); + }).pipe(Effect.provide(layer)); + }), +); + effectIt.effect("force-deleting a project removes an already-cold archived thread", () => Effect.gen(function* () { const events = yield* PubSub.unbounded(); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 5d9ef5aa5a8..32ef4410b5d 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -2,6 +2,7 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; 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"; @@ -32,6 +33,25 @@ function lifecycleJobKey(job: ThreadLifecycleJob): string { return job.type === "compact-legacy-storage" ? job.type : `${job.type}:${job.threadId}`; } +export const enqueueLifecycleJobOnce = ( + scheduledJobs: Set, + key: string, + enqueue: Effect.Effect, +) => + Effect.suspend(() => { + if (scheduledJobs.has(key)) return Effect.void; + scheduledJobs.add(key); + return enqueue.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : Effect.sync(() => { + scheduledJobs.delete(key); + }), + ), + ); + }); + export const logCleanupCauseUnlessInterrupted = ({ effect, message, @@ -166,12 +186,7 @@ const make = Effect.gen(function* () { ); const enqueueLifecycleJob = (job: ThreadLifecycleJob) => - Effect.suspend(() => { - const key = lifecycleJobKey(job); - if (scheduledJobs.has(key)) return Effect.void; - scheduledJobs.add(key); - return worker.enqueue(job); - }); + enqueueLifecycleJobOnce(scheduledJobs, lifecycleJobKey(job), worker.enqueue(job)); const enqueuePendingLifecycleJobs = Effect.fn("enqueuePendingThreadLifecycleJobs")(function* () { const pendingJobs = yield* Effect.all([ @@ -213,6 +228,7 @@ const make = Effect.gen(function* () { yield* Queue.take(retryRequested).pipe( Effect.andThen(Effect.sleep(THREAD_LIFECYCLE_RETRY_DELAY)), + Effect.andThen(Queue.poll(retryRequested)), Effect.andThen(enqueuePendingLifecycleJobs()), Effect.andThen(enqueueLifecycleJob({ type: "compact-legacy-storage" })), Effect.forever, diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 8a54359974d..610683e038f 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -77,6 +77,24 @@ describe("makeCatalogBackend", () => { }); describe("upgradeConnectionDatabase", () => { + it("does not clear the empty thread store when creating a new database", () => { + const stores = new Set(); + const clear = vi.fn(); + const database = { + objectStoreNames: { contains: (name: string) => stores.has(name) }, + createObjectStore: vi.fn((name: string) => stores.add(name)), + } as unknown as IDBDatabase; + const transaction = { + objectStore: vi.fn(() => ({ clear })), + } as unknown as IDBTransaction; + + upgradeConnectionDatabase(database, transaction, 0); + + expect(stores).toEqual(new Set(["catalog", "shell", "thread", "server-config", "vcs-refs"])); + expect(transaction.objectStore).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + }); + it("clears existing thread snapshots when upgrading to archive-time cache eviction", () => { const stores = new Set(["catalog", "shell", "thread", "server-config", "vcs-refs"]); const clear = vi.fn(); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4981a33621e..480bf18d82c 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -136,7 +136,7 @@ export function upgradeConnectionDatabase( database.createObjectStore(VCS_REFS_STORE_NAME); } - if (oldVersion < ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_VERSION) { + if (oldVersion > 0 && oldVersion < ARCHIVED_THREAD_CACHE_EVICTION_DATABASE_VERSION) { transaction?.objectStore(THREAD_STORE_NAME).clear(); } } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 29298418da9..0e0cc260f07 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -525,6 +525,28 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("removes cached data when an archived thread arrives in a snapshot", () => + Effect.gen(function* () { + const archivedThread = { + ...BASE_THREAD, + archivedAt: "2026-04-01T02:00:00.000Z", + }; + const harness = yield* makeHarness({ cached: BASE_THREAD }); + + yield* Queue.offer(harness.inputs, snapshot(archivedThread)); + const state = yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.archivedAt !== null, + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(Option.getOrThrow(state.data).archivedAt).toBe("2026-04-01T02:00:00.000Z"); + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + expect(yield* Ref.get(harness.savedThreads)).toEqual([]); + }), + ); + it.effect("does not restore an out-of-band cache eviction from queued or teardown writes", () => Effect.gen(function* () { const savedThreads = yield* Effect.scoped( diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index cd316b35dd0..05229b2829e 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -197,6 +197,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); + if (item.snapshot.thread.archivedAt !== null) { + yield* removeCachedThread(); + } return; } From 7bb3daafa7271033ec9909b175450befe510de5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 10:52:56 +0100 Subject: [PATCH 36/63] Preserve archived cleanup in Sidebar V2 project removal --- apps/web/src/components/SidebarV2.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ce444807a14..41b18a6d993 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -863,13 +863,14 @@ export default function SidebarV2() { ? [ `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, `Path: ${project.workspaceRoot}`, - "This permanently clears conversation history for those threads.", + "This permanently clears conversation history for those threads and any archived threads.", "This removes only the project entry, not the files on disk.", "This action cannot be undone.", ].join("\n") : [ `Remove project "${project.title}"?`, `Path: ${project.workspaceRoot}`, + "Any archived conversation history for this project will also be permanently deleted.", "This removes only the project entry, not the files on disk.", ].join("\n"), ), @@ -881,7 +882,9 @@ export default function SidebarV2() { environmentId: project.environmentId, input: { projectId: project.id, - ...(projectThreads.length > 0 ? { force: true } : {}), + // Archived shells are intentionally absent from `threads`, but the + // server still needs force=true to delete them and their cold bundle. + force: true, }, }); if (result._tag === "Failure") { From 68a30ae2e0eeedc553ffddc76b775ffa0f4e949a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 10:57:59 +0100 Subject: [PATCH 37/63] Document archived cleanup in Sidebar V2 project removal --- BRANCH_DETAILS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index f3fc67cd5a2..567ed57e7c9 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -7,7 +7,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. -- Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. +- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` always dispatches a forced deletion after confirmation, even when the live shell reports no threads because archived shells are excluded from normal navigation state. Its confirmation warns that archived conversation history is included. Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. @@ -24,6 +24,7 @@ Primary files: - `apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts` - `apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts` - `apps/server/scripts/t3-sqlite-state.ts` +- `apps/web/src/components/SidebarV2.tsx` ## Sidebar And Shell Consistency From e189a8004c10709e1a02ab18284bcc9f94fae948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 11:10:28 +0100 Subject: [PATCH 38/63] Preserve live-thread safeguard during archived project deletion - Add an archived-only project deletion command option - Reject archived-only deletion when any live thread remains - Use the option when Sidebar V2 has no visible threads --- BRANCH_DETAILS.md | 2 +- .../src/orchestration/decider.delete.test.ts | 81 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 7 +- apps/web/src/components/SidebarV2.tsx | 11 ++- packages/contracts/src/orchestration.ts | 1 + 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 567ed57e7c9..0813ed4b72e 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -7,7 +7,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. -- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` always dispatches a forced deletion after confirmation, even when the live shell reports no threads because archived shells are excluded from normal navigation state. Its confirmation warns that archived conversation history is included. Forced project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. +- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` dispatches a forced deletion after confirming known live threads. When the live shell reports no threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state; the server removes archived cold bundles but still rejects any unseen live thread. Its confirmation warns that archived conversation history is included. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, while `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717f..b861e1e1f0a 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -154,6 +154,87 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { }), ); + it.effect("rejects archived-only deletion when the project has a live thread", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const withArchivedThread = yield* projectEvent(readModel, { + sequence: 4, + eventId: asEventId("evt-thread-archive-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.archived", + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: asCommandId("cmd-thread-archive-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-archive-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + archivedAt: "2026-01-01T00:01:00.000Z", + updatedAt: "2026-01-01T00:01:00.000Z", + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-archived-only-mixed"), + projectId: asProjectId("project-delete"), + deleteArchivedThreads: true, + }, + readModel: withArchivedThread, + }), + ); + + expect(error.message).toContain("cannot be deleted without force=true"); + }), + ); + + it.effect("deletes a project containing only archived threads without force", () => + Effect.gen(function* () { + let readModel = yield* seedReadModel; + for (const [index, threadId] of ["thread-delete-1", "thread-delete-2"].entries()) { + const sequence = readModel.snapshotSequence + 1; + const archivedAt = `2026-01-01T00:0${index + 1}:00.000Z`; + readModel = yield* projectEvent(readModel, { + sequence, + eventId: asEventId(`evt-thread-archive-${index + 1}`), + aggregateKind: "thread", + aggregateId: asThreadId(threadId), + type: "thread.archived", + occurredAt: archivedAt, + commandId: asCommandId(`cmd-thread-archive-${index + 1}`), + causationEventId: null, + correlationId: asCommandId(`cmd-thread-archive-${index + 1}`), + metadata: {}, + payload: { + threadId: asThreadId(threadId), + archivedAt, + updatedAt: archivedAt, + }, + }); + } + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-archived-only"), + projectId: asProjectId("project-delete"), + deleteArchivedThreads: true, + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + + expect(events.map((event) => event.type)).toEqual([ + "thread.deleted", + "thread.deleted", + "project.deleted", + ]); + }), + ); + it.effect("reuses thread.delete semantics when force-deleting a non-empty project", () => Effect.gen(function* () { const readModel = yield* seedReadModel; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index cba967afc7c..46c8dd10c1e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -239,7 +239,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( (thread) => thread.deletedAt === null, ); - if (activeThreads.length > 0 && command.force !== true) { + const hasLiveThreads = activeThreads.some((thread) => thread.archivedAt === null); + if ( + activeThreads.length > 0 && + command.force !== true && + (command.deleteArchivedThreads !== true || hasLiveThreads) + ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 41b18a6d993..b50509af156 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -882,9 +882,14 @@ export default function SidebarV2() { environmentId: project.environmentId, input: { projectId: project.id, - // Archived shells are intentionally absent from `threads`, but the - // server still needs force=true to delete them and their cold bundle. - force: true, + ...(projectThreads.length > 0 + ? { force: true } + : { + // Archived shells are intentionally absent from `threads`. + // Keep the server's live-thread precondition while opting their + // cold bundles into project removal. + deleteArchivedThreads: true, + }), }, }); if (result._tag === "Failure") { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e1ddbdbd0cc..7981ee89748 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -531,6 +531,7 @@ const ProjectDeleteCommand = Schema.Struct({ commandId: CommandId, projectId: ProjectId, force: Schema.optional(Schema.Boolean), + deleteArchivedThreads: Schema.optional(Schema.Boolean), }); const ThreadCreateCommand = Schema.Struct({ From f19b6150e9dc7074d0e9c8dd98ce18085eb47b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 11:17:10 +0100 Subject: [PATCH 39/63] Harden archived project deletion safeguards - Clarify archived-only deletion predicates and confirmation copy - Cover live-thread rejection and optional contract compatibility --- .../src/orchestration/decider.delete.test.ts | 89 +++++++++++-------- apps/server/src/orchestration/decider.ts | 16 ++-- apps/web/src/components/SidebarV2.tsx | 5 +- packages/contracts/src/orchestration.test.ts | 13 +++ 4 files changed, 75 insertions(+), 48 deletions(-) diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index b861e1e1f0a..32ada908540 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -6,6 +6,7 @@ import { ThreadId, type OrchestrationCommand, type OrchestrationEvent, + type OrchestrationReadModel, ProviderInstanceId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -102,6 +103,27 @@ const seedReadModel = Effect.gen(function* () { }); }); +function archiveThread(readModel: OrchestrationReadModel, threadId: ThreadId, index: number) { + const archivedAt = `2026-01-01T00:0${index}:00.000Z`; + return projectEvent(readModel, { + sequence: readModel.snapshotSequence + 1, + eventId: asEventId(`evt-thread-archive-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.archived", + occurredAt: archivedAt, + commandId: asCommandId(`cmd-thread-archive-${index}`), + causationEventId: null, + correlationId: asCommandId(`cmd-thread-archive-${index}`), + metadata: {}, + payload: { + threadId, + archivedAt, + updatedAt: archivedAt, + }, + }); +} + type PlannedEvent = Omit; function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray) { @@ -154,26 +176,13 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { }), ); - it.effect("rejects archived-only deletion when the project has a live thread", () => + it.effect("rejects deleteArchivedThreads when the project still has a live thread", () => Effect.gen(function* () { const readModel = yield* seedReadModel; - const withArchivedThread = yield* projectEvent(readModel, { - sequence: 4, - eventId: asEventId("evt-thread-archive-1"), - aggregateKind: "thread", - aggregateId: asThreadId("thread-delete-1"), - type: "thread.archived", - occurredAt: "2026-01-01T00:01:00.000Z", - commandId: asCommandId("cmd-thread-archive-1"), - causationEventId: null, - correlationId: asCommandId("cmd-thread-archive-1"), - metadata: {}, - payload: { - threadId: asThreadId("thread-delete-1"), - archivedAt: "2026-01-01T00:01:00.000Z", - updatedAt: "2026-01-01T00:01:00.000Z", - }, - }); + const withArchivedThread = yield* archiveThread(readModel, asThreadId("thread-delete-1"), 1); + expect( + withArchivedThread.threads.find((thread) => thread.id === "thread-delete-2")?.archivedAt, + ).toBeNull(); const error = yield* Effect.flip( decideOrchestrationCommand({ @@ -187,33 +196,39 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { }), ); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); expect(error.message).toContain("cannot be deleted without force=true"); }), ); - it.effect("deletes a project containing only archived threads without force", () => + it.effect("rejects deleting archived threads without explicit opt-in", () => Effect.gen(function* () { let readModel = yield* seedReadModel; for (const [index, threadId] of ["thread-delete-1", "thread-delete-2"].entries()) { - const sequence = readModel.snapshotSequence + 1; - const archivedAt = `2026-01-01T00:0${index + 1}:00.000Z`; - readModel = yield* projectEvent(readModel, { - sequence, - eventId: asEventId(`evt-thread-archive-${index + 1}`), - aggregateKind: "thread", - aggregateId: asThreadId(threadId), - type: "thread.archived", - occurredAt: archivedAt, - commandId: asCommandId(`cmd-thread-archive-${index + 1}`), - causationEventId: null, - correlationId: asCommandId(`cmd-thread-archive-${index + 1}`), - metadata: {}, - payload: { - threadId: asThreadId(threadId), - archivedAt, - updatedAt: archivedAt, + readModel = yield* archiveThread(readModel, asThreadId(threadId), index + 1); + } + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-archived-no-opt-in"), + projectId: asProjectId("project-delete"), }, - }); + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("cannot be deleted without force=true"); + }), + ); + + it.effect("deletes a project containing only archived threads without force", () => + Effect.gen(function* () { + let readModel = yield* seedReadModel; + for (const [index, threadId] of ["thread-delete-1", "thread-delete-2"].entries()) { + readModel = yield* archiveThread(readModel, asThreadId(threadId), index + 1); } const result = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 46c8dd10c1e..5d9a73a3b80 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -236,25 +236,23 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); - const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( + const undeletedThreads = listThreadsByProjectId(readModel, command.projectId).filter( (thread) => thread.deletedAt === null, ); - const hasLiveThreads = activeThreads.some((thread) => thread.archivedAt === null); - if ( - activeThreads.length > 0 && - command.force !== true && - (command.deleteArchivedThreads !== true || hasLiveThreads) - ) { + const hasLiveThreads = undeletedThreads.some((thread) => thread.archivedAt === null); + const canDeleteNonEmptyProject = + command.force === true || (command.deleteArchivedThreads === true && !hasLiveThreads); + if (undeletedThreads.length > 0 && !canDeleteNonEmptyProject) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, }); } - if (activeThreads.length > 0) { + if (undeletedThreads.length > 0) { return yield* decideCommandSequence({ readModel, commands: [ - ...activeThreads.map( + ...undeletedThreads.map( (thread): Extract => ({ type: "thread.delete", commandId: command.commandId, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index b50509af156..bdebb15ed5e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -863,15 +863,16 @@ export default function SidebarV2() { ? [ `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, `Path: ${project.workspaceRoot}`, - "This permanently clears conversation history for those threads and any archived threads.", + "This permanently clears conversation history for those threads and any archived conversations in this project.", "This removes only the project entry, not the files on disk.", "This action cannot be undone.", ].join("\n") : [ `Remove project "${project.title}"?`, `Path: ${project.workspaceRoot}`, - "Any archived conversation history for this project will also be permanently deleted.", + "If this project has archived conversations, their history will also be permanently deleted.", "This removes only the project entry, not the files on disk.", + "This action cannot be undone.", ].join("\n"), ), ); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 1ebc23a483b..27082311120 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -158,6 +158,19 @@ it.effect("decodes project.create with createWorkspaceRootIfMissing enabled", () }), ); +it.effect("decodes project.delete without an archived-thread opt-in", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "project.delete", + commandId: "cmd-project-delete", + projectId: "project-delete", + }); + + assert.strictEqual(parsed.type, "project.delete"); + assert.strictEqual(parsed.deleteArchivedThreads, undefined); + }), +); + it.effect("decodes historical project.created payloads with a default provider", () => Effect.gen(function* () { const parsed = yield* decodeProjectCreatedPayload({ From e35e66f5637e11a0a43bb66cb9867b4a5d4f451e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 11:28:07 +0100 Subject: [PATCH 40/63] Fix project delete compatibility test narrowing - Guard the decoded command before optional-field checks - Keep upstream project.delete payload compatibility covered --- packages/contracts/src/orchestration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 27082311120..45a0dc3855c 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -166,7 +166,9 @@ it.effect("decodes project.delete without an archived-thread opt-in", () => projectId: "project-delete", }); - assert.strictEqual(parsed.type, "project.delete"); + if (parsed.type !== "project.delete") { + assert.fail(`Expected project.delete, received ${parsed.type}`); + } assert.strictEqual(parsed.deleteArchivedThreads, undefined); }), ); From 64bd96594878d7ffd8c4afa2f1a1c49916324a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 18:14:31 +0100 Subject: [PATCH 41/63] Test archived project removal grouping --- apps/web/src/components/Sidebar.logic.test.ts | 39 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 25 ++++++++++++ apps/web/src/components/SidebarV2.tsx | 25 ++++++------ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 83f22b477d6..49fad63432e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -4,6 +4,7 @@ import { buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, filterVisibleSidebarThreads, + getArchivedProjectRemovalWarning, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -15,6 +16,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveArchivedProjectRemovalCommandOptions, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, @@ -1082,6 +1084,43 @@ describe("filterVisibleSidebarThreads", () => { }); }); +describe("archived project removal with grouped project actions", () => { + it("keeps archived-bundle command scope independent for each project member", () => { + expect( + [true, false].map((hasLiveThreads) => + resolveArchivedProjectRemovalCommandOptions(hasLiveThreads), + ), + ).toEqual([{ force: true }, { deleteArchivedThreads: true }]); + }); + + it("describes archived deletion for standalone and grouped removals", () => { + expect( + getArchivedProjectRemovalWarning({ + memberCount: 1, + hasLiveThreads: true, + }), + ).toBe( + "This permanently clears conversation history for those threads and any archived conversations in this project.", + ); + expect( + getArchivedProjectRemovalWarning({ + memberCount: 1, + hasLiveThreads: false, + }), + ).toBe( + "If this project has archived conversations, their history will also be permanently deleted.", + ); + expect( + getArchivedProjectRemovalWarning({ + memberCount: 2, + hasLiveThreads: false, + }), + ).toBe( + "If these projects have archived conversations, their history will also be permanently deleted.", + ); + }); +}); + describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a5b45024bea..ba62515f952 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -662,6 +662,31 @@ export function filterVisibleSidebarThreads< ); } +export function getArchivedProjectRemovalWarning(input: { + memberCount: number; + hasLiveThreads: boolean; +}): string { + const projectLabel = input.memberCount === 1 ? "this project" : "these projects"; + if (input.hasLiveThreads) { + return `This permanently clears conversation history for those threads and any archived conversations in ${projectLabel}.`; + } + const verb = input.memberCount === 1 ? "has" : "have"; + return `If ${projectLabel} ${verb} archived conversations, their history will also be permanently deleted.`; +} + +export function resolveArchivedProjectRemovalCommandOptions( + hasLiveThreads: boolean, +): { readonly force: true } | { readonly deleteArchivedThreads: true } { + if (hasLiveThreads) { + return { force: true }; + } + + // Archived shells are intentionally absent from the client's live thread + // list. Preserve the server's live-thread precondition while opting this + // project member's cold bundles into removal. + return { deleteArchivedThreads: true }; +} + export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ed25dba51a9..3da41609e36 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -94,10 +94,12 @@ import { cn } from "~/lib/utils"; import { filterVisibleSidebarThreads, firstValidTimestampMs, + getArchivedProjectRemovalWarning, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, + resolveArchivedProjectRemovalCommandOptions, resolveSidebarV2Status, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, @@ -977,9 +979,10 @@ export default function SidebarV2() { : []), ] : [`This removes ${members.length} grouped project entries.`]), - `This permanently clears conversation history for those threads and any archived conversations in ${ - members.length === 1 ? "this project" : "these projects" - }.`, + getArchivedProjectRemovalWarning({ + memberCount: members.length, + hasLiveThreads: true, + }), isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", @@ -995,9 +998,10 @@ export default function SidebarV2() { : []), ] : [`This removes ${members.length} grouped project entries.`]), - `If ${ - members.length === 1 ? "this project has" : "these projects have" - } archived conversations, their history will also be permanently deleted.`, + getArchivedProjectRemovalWarning({ + memberCount: members.length, + hasLiveThreads: false, + }), isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", @@ -1026,14 +1030,7 @@ export default function SidebarV2() { environmentId: project.environmentId, input: { projectId: project.id, - ...(memberThreads.length > 0 - ? { force: true } - : { - // Archived shells are intentionally absent from `threads`. - // Keep the server's live-thread precondition while opting - // this member's cold bundles into project removal. - deleteArchivedThreads: true, - }), + ...resolveArchivedProjectRemovalCommandOptions(memberThreads.length > 0), }, }); if (result._tag === "Failure") { From bfb35d2209e278a52ce60f87520c669a27772c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 18:15:44 +0100 Subject: [PATCH 42/63] Document archived project removal helpers --- BRANCH_DETAILS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 0813ed4b72e..220215bb160 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -7,7 +7,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. -- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` dispatches a forced deletion after confirming known live threads. When the live shell reports no threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state; the server removes archived cold bundles but still rejects any unseen live thread. Its confirmation warns that archived conversation history is included. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, while `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. +- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus per-member command-option selection, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. @@ -24,6 +24,8 @@ Primary files: - `apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts` - `apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts` - `apps/server/scripts/t3-sqlite-state.ts` +- `apps/web/src/components/Sidebar.logic.ts` +- `apps/web/src/components/Sidebar.logic.test.ts` - `apps/web/src/components/SidebarV2.tsx` ## Sidebar And Shell Consistency From 355d1ebc37a1914c8f42f0e0fecb84b122cf2ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 18:27:45 +0100 Subject: [PATCH 43/63] Cover grouped archived project removal cases --- apps/web/src/components/Sidebar.logic.test.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 49fad63432e..267f40b90ae 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1086,11 +1086,20 @@ describe("filterVisibleSidebarThreads", () => { describe("archived project removal with grouped project actions", () => { it("keeps archived-bundle command scope independent for each project member", () => { + const members = [ + { id: "project-with-live-threads", hasLiveThreads: true }, + { id: "archived-only-project", hasLiveThreads: false }, + ]; + expect( - [true, false].map((hasLiveThreads) => - resolveArchivedProjectRemovalCommandOptions(hasLiveThreads), - ), - ).toEqual([{ force: true }, { deleteArchivedThreads: true }]); + members.map((member) => ({ + id: member.id, + options: resolveArchivedProjectRemovalCommandOptions(member.hasLiveThreads), + })), + ).toEqual([ + { id: "project-with-live-threads", options: { force: true } }, + { id: "archived-only-project", options: { deleteArchivedThreads: true } }, + ]); }); it("describes archived deletion for standalone and grouped removals", () => { @@ -1110,6 +1119,14 @@ describe("archived project removal with grouped project actions", () => { ).toBe( "If this project has archived conversations, their history will also be permanently deleted.", ); + expect( + getArchivedProjectRemovalWarning({ + memberCount: 2, + hasLiveThreads: true, + }), + ).toBe( + "This permanently clears conversation history for those threads and any archived conversations in these projects.", + ); expect( getArchivedProjectRemovalWarning({ memberCount: 2, From 5af3948232f19fc90e75b2e759490704f4f74ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 18:35:04 +0100 Subject: [PATCH 44/63] Cover grouped project removal planning - Derive command options from each member's own live threads - Exercise mixed live and archived-only grouped removal - Keep branch behavior documentation aligned with the tested path --- BRANCH_DETAILS.md | 2 +- apps/web/src/components/Sidebar.logic.test.ts | 34 ++++++++++++++----- apps/web/src/components/Sidebar.logic.ts | 23 +++++++++++++ apps/web/src/components/SidebarV2.tsx | 11 +++--- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 220215bb160..a79cae156e6 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -7,7 +7,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. -- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus per-member command-option selection, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. +- Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus the per-member thread partitioning that selects each member's command options, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 267f40b90ae..190b01d1726 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildArchivedProjectRemovalPlans, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, filterVisibleSidebarThreads, @@ -16,7 +17,6 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, - resolveArchivedProjectRemovalCommandOptions, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, @@ -1085,20 +1085,36 @@ describe("filterVisibleSidebarThreads", () => { }); describe("archived project removal with grouped project actions", () => { - it("keeps archived-bundle command scope independent for each project member", () => { + it("derives archived-bundle command scope from each project member's threads", () => { const members = [ - { id: "project-with-live-threads", hasLiveThreads: true }, - { id: "archived-only-project", hasLiveThreads: false }, + { environmentId: "environment-live", id: "grouped-project" }, + { environmentId: "environment-archived", id: "grouped-project" }, + ]; + const projectThreads = [ + { + environmentId: "environment-live", + projectId: "grouped-project", + id: "thread-live", + }, ]; expect( - members.map((member) => ({ - id: member.id, - options: resolveArchivedProjectRemovalCommandOptions(member.hasLiveThreads), + buildArchivedProjectRemovalPlans(members, projectThreads).map((plan) => ({ + environmentId: plan.member.environmentId, + threadIds: plan.memberThreads.map((thread) => thread.id), + options: plan.commandOptions, })), ).toEqual([ - { id: "project-with-live-threads", options: { force: true } }, - { id: "archived-only-project", options: { deleteArchivedThreads: true } }, + { + environmentId: "environment-live", + threadIds: ["thread-live"], + options: { force: true }, + }, + { + environmentId: "environment-archived", + threadIds: [], + options: { deleteArchivedThreads: true }, + }, ]); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ba62515f952..a68a297a46e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -687,6 +687,29 @@ export function resolveArchivedProjectRemovalCommandOptions( return { deleteArchivedThreads: true }; } +export function buildArchivedProjectRemovalPlans< + TMember extends { readonly environmentId: string; readonly id: string }, + TThread extends { readonly environmentId: string; readonly projectId: string }, +>( + members: readonly TMember[], + projectThreads: readonly TThread[], +): { + readonly member: TMember; + readonly memberThreads: TThread[]; + readonly commandOptions: { readonly force: true } | { readonly deleteArchivedThreads: true }; +}[] { + return members.map((member) => { + const memberThreads = projectThreads.filter( + (thread) => thread.environmentId === member.environmentId && thread.projectId === member.id, + ); + return { + member, + memberThreads, + commandOptions: resolveArchivedProjectRemovalCommandOptions(memberThreads.length > 0), + }; + }); +} + export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3da41609e36..2f92a3572a2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -92,6 +92,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + buildArchivedProjectRemovalPlans, filterVisibleSidebarThreads, firstValidTimestampMs, getArchivedProjectRemovalWarning, @@ -99,7 +100,6 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, - resolveArchivedProjectRemovalCommandOptions, resolveSidebarV2Status, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, @@ -963,6 +963,7 @@ export default function SidebarV2() { const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); + const memberRemovalPlans = buildArchivedProjectRemovalPlans(members, projectThreads); const isWholeGroup = members.length === projectGroup.memberProjects.length; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? projectGroup.displayName; @@ -1013,11 +1014,7 @@ export default function SidebarV2() { const draftStore = useComposerDraftStore.getState(); let shouldNavigate = false; - for (const project of members) { - const memberThreads = projectThreads.filter( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, - ); + for (const { member: project, memberThreads, commandOptions } of memberRemovalPlans) { const projectRef = scopeProjectRef(project.environmentId, project.id); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ @@ -1030,7 +1027,7 @@ export default function SidebarV2() { environmentId: project.environmentId, input: { projectId: project.id, - ...resolveArchivedProjectRemovalCommandOptions(memberThreads.length > 0), + ...commandOptions, }, }); if (result._tag === "Failure") { From 7d473463df0770c75414f3e30bc5dcf7d9f2f34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 20:35:08 +0100 Subject: [PATCH 45/63] Retry archived thread cache synchronization - Retry failed shell evictions while removed threads stay absent - Revive detail caches before persisting active snapshots - Cover reconnect and eviction retry paths --- BRANCH_DETAILS.md | 2 + .../src/state/shell-sync.test.ts | 93 +++++++++++++++++++ packages/client-runtime/src/state/shell.ts | 22 ++++- .../client-runtime/src/state/threadCache.ts | 6 +- .../src/state/threads-sync.test.ts | 38 ++++++++ packages/client-runtime/src/state/threads.ts | 4 +- 6 files changed, 158 insertions(+), 7 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index a79cae156e6..0602d08591a 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -36,6 +36,8 @@ Authoritative shell synchronization is shared runtime behavior in `packages/clie Persisted web and mobile thread details are also fast-paint caches, not an archive source of truth. A `thread.archived` detail event, an authoritative archived detail snapshot, successful local archive acknowledgement, or shell removal evicts the persisted detail through `packages/client-runtime/src/state/threadCache.ts`. Per-thread cache generations, eviction tombstones, and write locks prevent queued or idle-finalizer writes from recreating the snapshot. Shell additions and `thread.unarchived` events revive the cache while invalidating only writes captured during the tombstoned generation. Web IndexedDB migration version 5 and mobile SQLite migration version 2 each clear legacy thread-detail caches once; active details repopulate on demand. +Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. + `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived. General mobile time rendering is unchanged. diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b4abed25b48..21bb6f15d8d 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -283,6 +283,99 @@ describe("environment shell synchronization", () => { }), ); + it.effect("retries failed detail eviction while the thread remains absent", () => + Effect.gen(function* () { + const staleThreadId = ThreadId.make("stale-thread"); + const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [{ id: staleThreadId } as never], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const httpSnapshot: OrchestrationShellSnapshot = { + ...cachedSnapshot, + snapshotSequence: 2, + threads: [], + updatedAt: "2026-06-06T00:00:01.000Z", + }; + const events = yield* Queue.unbounded(); + const capturedInput = yield* SubscriptionRef.make(false); + const evictionAttempts = yield* Ref.make(0); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + Stream.unwrap( + SubscriptionRef.set(capturedInput, true).pipe(Effect.as(Stream.fromQueue(events))), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => + Ref.updateAndGet(evictionAttempts, (attempts) => attempts + 1).pipe( + Effect.flatMap((attempts) => + attempts === 1 + ? Effect.fail( + new Persistence.ConnectionPersistenceError({ + operation: "remove-thread", + message: "temporary failure", + }), + ) + : Effect.void, + ), + ), + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService( + ShellSnapshotLoader, + ShellSnapshotLoader.of({ + load: () => Effect.succeed(Option.some(httpSnapshot)), + }), + ), + ); + + yield* SubscriptionRef.changes(capturedInput).pipe( + Stream.filter((subscribed) => subscribed), + Stream.runHead, + ); + expect(yield* Ref.get(evictionAttempts)).toBe(1); + + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { + ...httpSnapshot, + snapshotSequence: 3, + updatedAt: "2026-06-06T00:00:02.000Z", + }, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 3, + ), + Stream.runHead, + ); + + expect(yield* Ref.get(evictionAttempts)).toBe(2); + }), + ); + it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index e18a9589f4b..32e083b5f2a 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -73,6 +73,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") error: Option.none(), }); const awaitingCompletion = yield* Ref.make(false); + const pendingThreadEvictions = yield* Ref.make>(new Set()); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -182,12 +183,25 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") .map((thread) => thread.id), }); + const evictionCandidates = new Set([ + ...(yield* Ref.get(pendingThreadEvictions)), + ...removedThreadIds, + ]); + for (const threadId of nextThreadIds) { + evictionCandidates.delete(threadId); + } // Advance cache tombstones before publishing the new shell so detail // observers cannot enqueue an obsolete write in the transition window. - yield* Effect.forEach( - removedThreadIds, - (threadId) => evictCachedThread(cache, environmentId, threadId), - { discard: true }, + // Keep failed disk removals pending while the shell still omits the thread; + // a later snapshot or event can then retry instead of stranding stale data. + const evictionResults = yield* Effect.forEach(evictionCandidates, (threadId) => + evictCachedThread(cache, environmentId, threadId).pipe( + Effect.map((removed) => [threadId, removed] as const), + ), + ); + yield* Ref.set( + pendingThreadEvictions, + new Set(evictionResults.filter(([, removed]) => !removed).map(([threadId]) => threadId)), ); yield* Effect.forEach( addedThreadIds, diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts index a5c8b57f5f0..a23c0eca506 100644 --- a/packages/client-runtime/src/state/threadCache.ts +++ b/packages/client-runtime/src/state/threadCache.ts @@ -164,12 +164,13 @@ export const evictCachedThread = Effect.fn("EnvironmentThreadCache.evict")(funct environmentId: EnvironmentId, threadId: ThreadId, ) { - yield* withThreadCacheState(cache, environmentId, threadId, (state) => + return yield* withThreadCacheState(cache, environmentId, threadId, (state) => state.lock.withPermit( Effect.gen(function* () { state.generation += 1; state.evicted = true; - yield* cache.removeThread(environmentId, threadId).pipe( + return yield* cache.removeThread(environmentId, threadId).pipe( + Effect.as(true), Effect.catch((error) => Effect.logWarning("Could not evict cached thread detail.").pipe( Effect.annotateLogs({ @@ -177,6 +178,7 @@ export const evictCachedThread = Effect.fn("EnvironmentThreadCache.evict")(funct threadId, ...safeErrorLogAttributes(error), }), + Effect.as(false), ), ), ); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index cf69905ef68..58035a3c0bb 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -678,6 +678,44 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("persists thread detail again after an authoritative active snapshot", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer( + harness.inputs, + snapshot({ + ...BASE_THREAD, + archivedAt: "2026-04-01T02:00:00.000Z", + }), + ); + yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.archivedAt !== null, + ); + + yield* Queue.offer( + harness.inputs, + snapshot({ + ...BASE_THREAD, + title: "Restored from snapshot", + updatedAt: "2026-04-01T03:00:00.000Z", + }), + ); + yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.title === "Restored from snapshot", + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.thread).toMatchObject({ + title: "Restored from snapshot", + archivedAt: null, + }); + }), + ); + it.effect("does not resurrect a deleted thread when the app returns to the foreground", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 05229b2829e..096d2ca4978 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -196,10 +196,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); - yield* setThread(item.snapshot.thread); if (item.snapshot.thread.archivedAt !== null) { yield* removeCachedThread(); + } else { + yield* reviveCachedThread(cache, environmentId, threadId); } + yield* setThread(item.snapshot.thread); return; } From e9abbf0a6e9e3806ace2b73f0708984b6b93cc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 23:45:17 +0100 Subject: [PATCH 46/63] Retry restored archive cleanup on unarchive replay - Finalize matching restored bundles for accepted unarchive receipts - Cover transient finalization failure without duplicate events --- BRANCH_DETAILS.md | 2 +- .../Layers/OrchestrationEngine.test.ts | 27 +++++++++++++-- .../Layers/OrchestrationEngine.ts | 34 +++++++++++++------ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 0602d08591a..b610681ab52 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -9,7 +9,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. - Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus the per-member thread partitioning that selects each member's command options, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. -- Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. +- Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. Retrying an accepted unarchive receipt also retries idempotent bundle finalization for that same thread, so a transient post-commit cleanup failure cannot strand restored archive chunks. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. - A `restored` manifest protects only the archive epoch that an unarchive command reserved. If post-commit bundle finalization fails and the user later archives the thread again, the newer shell timestamp lets lifecycle work replace the stale manifest and move the conversation cold. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 4e0e25fbd3b..319fad055c2 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -880,6 +880,7 @@ describe("OrchestrationEngine", () => { const finishedThreadIds: ThreadId[] = []; let nextSequence = 1; let restoreOnUnarchive = false; + let failNextFinish = false; const flakyStore: OrchestrationEventStoreShape = { append(event) { @@ -911,8 +912,14 @@ describe("OrchestrationEngine", () => { rolledBackThreadIds.push(threadId); }), finishRestoreTree: (threadId) => - Effect.sync(() => { - finishedThreadIds.push(threadId); + Effect.gen(function* () { + yield* Effect.sync(() => { + finishedThreadIds.push(threadId); + }); + if (failNextFinish) { + failNextFinish = false; + return yield* Effect.die("finish restore failed"); + } }), deleteThread: () => Effect.void, removeProviderLogs: () => Effect.void, @@ -996,6 +1003,22 @@ describe("OrchestrationEngine", () => { expect(unarchiveFailure.message).toContain("unarchive append failed"); expect(rolledBackThreadIds).toEqual([ThreadId.make("thread-cold-rollback")]); expect(finishedThreadIds).toEqual([]); + + failNextFinish = true; + const acceptedCommand = { + type: "thread.unarchive", + commandId: CommandId.make("cmd-cold-unarchive-accepted"), + threadId: ThreadId.make("thread-cold-rollback"), + } as const; + const acceptedResult = yield* engine.dispatch(acceptedCommand); + const retriedResult = yield* engine.dispatch(acceptedCommand); + + expect(retriedResult).toEqual(acceptedResult); + expect(finishedThreadIds).toEqual([ + ThreadId.make("thread-cold-rollback"), + ThreadId.make("thread-cold-rollback"), + ]); + expect(events.filter((event) => event.type === "thread.unarchived")).toHaveLength(1); }).pipe(Effect.provide(testLayer)); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 85c33335777..4457ac08f34 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -92,6 +92,18 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandQueue = yield* Queue.unbounded(); const eventPubSub = yield* PubSub.unbounded(); + const finishRestoredUnarchiveTree = (threadId: ThreadId) => + Option.isSome(threadColdStorage) + ? threadColdStorage.value.finishRestoreTree(threadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to finalize restored archive bundle", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ) + : Effect.void; + const projectEventsOntoReadModel = ( baseReadModel: OrchestrationReadModel, events: ReadonlyArray, @@ -139,11 +151,20 @@ const makeOrchestrationEngine = Effect.gen(function* () { "orchestration.aggregate_id": aggregateRef.aggregateId, }); + const unarchiveThreadId = + envelope.command.type === "thread.unarchive" ? envelope.command.threadId : null; const existingReceipt = yield* commandReceiptRepository.getByCommandId({ commandId: envelope.command.commandId, }); if (Option.isSome(existingReceipt)) { if (existingReceipt.value.status === "accepted") { + if ( + unarchiveThreadId !== null && + existingReceipt.value.aggregateKind === "thread" && + existingReceipt.value.aggregateId === unarchiveThreadId + ) { + yield* finishRestoredUnarchiveTree(unarchiveThreadId); + } return { sequence: existingReceipt.value.resultSequence, }; @@ -154,8 +175,6 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } - const unarchiveThreadId = - envelope.command.type === "thread.unarchive" ? envelope.command.threadId : null; if (unarchiveThreadId !== null && Option.isSome(threadColdStorage)) { const restored = yield* threadColdStorage.value.restoreTree(unarchiveThreadId).pipe( Effect.mapError( @@ -242,15 +261,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { restoredUnarchiveCommitted = restoredUnarchiveThreadId !== null; commandReadModel = committedCommand.nextCommandReadModel; - if (restoredUnarchiveThreadId !== null && Option.isSome(threadColdStorage)) { - yield* threadColdStorage.value.finishRestoreTree(restoredUnarchiveThreadId).pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to finalize restored archive bundle", { - threadId: restoredUnarchiveThreadId, - cause: Cause.pretty(cause), - }), - ), - ); + if (restoredUnarchiveThreadId !== null) { + yield* finishRestoredUnarchiveTree(restoredUnarchiveThreadId); } for (const [index, event] of committedCommand.committedEvents.entries()) { yield* PubSub.publish(eventPubSub, event); From 3f385ab0df56da67d94cfbdc82fe359c02473784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 24 Jul 2026 00:26:15 +0100 Subject: [PATCH 47/63] Keep archive sorting consistent during transitions - Exclude optimistically archived threads from project activity - Keep invalid mobile archive dates behind valid timestamps --- BRANCH_DETAILS.md | 2 +- .../archive/archivedThreadList.test.ts | 56 ++++++++++++ .../features/archive/archivedThreadList.ts | 34 +++++-- apps/web/src/components/CommandPalette.tsx | 12 ++- apps/web/src/components/Sidebar.logic.test.ts | 89 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 25 ++++-- apps/web/src/components/SidebarV2.tsx | 16 ++-- .../src/components/chat/DraftHeroHeadline.tsx | 6 ++ apps/web/src/routes/_chat.index.tsx | 13 ++- 9 files changed, 231 insertions(+), 22 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index b610681ab52..f77c1ecd1df 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -40,7 +40,7 @@ Failed shell-driven detail-cache removals remain pending while the authoritative `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. -Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived. General mobile time rendering is unchanged. +Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived, and sort missing or invalid archive dates after valid dates in both sort directions. General mobile time rendering is unchanged. ## Development Ports diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 5dd947ebd1b..34527e14924 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -143,6 +143,62 @@ describe("buildArchivedThreadGroups", () => { expect(result).toEqual([]); }); + + it("sorts invalid archive timestamps after valid timestamps in either direction", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const invalid = makeThread({ + archivedAt: "not-a-timestamp", + id: ThreadId.make("thread-invalid"), + projectId: project.id, + title: "Invalid", + }); + const anotherInvalid = makeThread({ + archivedAt: "also-not-a-timestamp", + id: ThreadId.make("thread-another-invalid"), + projectId: project.id, + title: "Another invalid", + }); + const older = makeThread({ + id: ThreadId.make("thread-older"), + projectId: project.id, + title: "Older", + }); + const newer = makeThread({ + archivedAt: "2026-06-03T00:00:00.000Z", + id: ThreadId.make("thread-newer"), + projectId: project.id, + title: "Newer", + }); + const snapshots = [makeSnapshot([project], [invalid, anotherInvalid, older, newer])]; + + const newest = buildArchivedThreadGroups({ + snapshots, + environmentLabels: {}, + environmentId: null, + searchQuery: "", + sortOrder: "newest", + }); + const oldest = buildArchivedThreadGroups({ + snapshots, + environmentLabels: {}, + environmentId: null, + searchQuery: "", + sortOrder: "oldest", + }); + + expect(newest[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-newer", + "thread-older", + "thread-another-invalid", + "thread-invalid", + ]); + expect(oldest[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-older", + "thread-newer", + "thread-another-invalid", + "thread-invalid", + ]); + }); }); describe("formatArchivedThreadRelativeTime", () => { diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 520545ec82f..a993f1cd320 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -20,19 +20,33 @@ export interface ArchivedThreadGroup { readonly threads: ReadonlyArray; } -function archiveTimestamp(thread: EnvironmentThreadShell): number { - const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt); - return Number.isNaN(timestamp) ? 0 : timestamp; +function parseTimestamp(input: string): number | null { + const timestamp = Date.parse(input); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function archiveTimestamp(thread: EnvironmentThreadShell): number | null { + return parseTimestamp(thread.archivedAt ?? thread.updatedAt); } export function formatArchivedThreadRelativeTime(input: string): string | null { - return Number.isNaN(Date.parse(input)) ? null : relativeTime(input); + return parseTimestamp(input) === null ? null : relativeTime(input); } function matchesQuery(value: string | null, query: string): boolean { return value?.toLocaleLowerCase().includes(query) ?? false; } +function makeArchiveTimestampOrder(sortOrder: ArchivedThreadSortOrder): Order.Order { + const timestampOrder = sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + return Order.make((left, right) => { + if (left === null && right === null) return 0; + if (left === null) return 1; + if (right === null) return -1; + return timestampOrder(left, right); + }); +} + export function buildArchivedThreadGroups(input: { readonly snapshots: ReadonlyArray; readonly environmentLabels: Readonly>; @@ -77,14 +91,18 @@ export function buildArchivedThreadGroups(input: { continue; } - const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + const timestampOrder = makeArchiveTimestampOrder(input.sortOrder); groups.push({ key: scopedProjectKey(project.environmentId, project.id), project, threads: Arr.sort( matchingThreads, Order.mapInput( - Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }), + Order.Struct({ + timestamp: timestampOrder, + title: Order.String, + id: Order.String, + }), (thread: EnvironmentThreadShell) => ({ timestamp: archiveTimestamp(thread), title: thread.title, @@ -96,13 +114,13 @@ export function buildArchivedThreadGroups(input: { } } - const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + const timestampOrder = makeArchiveTimestampOrder(input.sortOrder); return Arr.sort( groups, Order.mapInput( Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }), (group: ArchivedThreadGroup) => ({ - timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, + timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : null, title: group.project.title, key: group.key, }), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ffe3264fbf8..45cb688e6b5 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -115,6 +115,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; +import { useOptimisticThreadArchiveStore } from "../optimisticThreadArchiveStore"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { Command, @@ -501,6 +502,9 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( + (state) => state.threadKeys, + ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); @@ -561,8 +565,14 @@ function OpenCommandPaletteDialog(props: { unsortedProjectGroups, threads, clientSettings.sidebarProjectSortOrder, + optimisticallyArchivedThreadKeys, ), - [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups], + [ + clientSettings.sidebarProjectSortOrder, + optimisticallyArchivedThreadKeys, + threads, + unsortedProjectGroups, + ], ); const contextualProjectRef = useMemo( () => diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5b683a99f94..21199f4d9b6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1588,6 +1588,47 @@ describe("sortScopedProjectsForSidebar", () => { "Archived-only project", ]); }); + + it("does not use optimistically archived threads as project activity", () => { + const visibleProjectId = ProjectId.make("project-visible"); + const optimisticProjectId = ProjectId.make("project-optimistic"); + const optimisticThread = makeThread({ + id: ThreadId.make("thread-optimistic"), + projectId: optimisticProjectId, + updatedAt: "2026-03-09T10:10:00.000Z", + }); + const sorted = sortScopedProjectsForSidebar( + [ + makeProject({ + id: visibleProjectId, + title: "Visible project", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: optimisticProjectId, + title: "Optimistic-only project", + updatedAt: "2026-03-09T10:00:00.000Z", + }), + ], + [ + makeThread({ + id: ThreadId.make("thread-visible"), + projectId: visibleProjectId, + updatedAt: "2026-03-09T10:02:00.000Z", + }), + optimisticThread, + ], + "updated_at", + new Set([ + scopedThreadKey(scopeThreadRef(optimisticThread.environmentId, optimisticThread.id)), + ]), + ); + + expect(sorted.map((project) => project.title)).toEqual([ + "Visible project", + "Optimistic-only project", + ]); + }); }); describe("sortLogicalProjectsForSidebar", () => { @@ -1625,4 +1666,52 @@ describe("sortLogicalProjectsForSidebar", () => { ), ).toEqual(["logical-newer", "logical-older"]); }); + + it("does not use optimistically archived threads as logical project activity", () => { + const visibleProjectId = ProjectId.make("project-visible"); + const optimisticProjectId = ProjectId.make("project-optimistic"); + const projects = [ + { + ...makeProject({ + id: visibleProjectId, + title: "Visible project", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + projectKey: "logical-visible", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: visibleProjectId }], + }, + { + ...makeProject({ + id: optimisticProjectId, + title: "Optimistic-only project", + updatedAt: "2026-03-09T10:00:00.000Z", + }), + projectKey: "logical-optimistic", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: optimisticProjectId }], + }, + ]; + const optimisticThread = makeThread({ + id: ThreadId.make("thread-optimistic"), + projectId: optimisticProjectId, + updatedAt: "2026-03-09T10:10:00.000Z", + }); + + expect( + sortLogicalProjectsForSidebar( + projects, + [ + makeThread({ + id: ThreadId.make("thread-visible"), + projectId: visibleProjectId, + updatedAt: "2026-03-09T10:02:00.000Z", + }), + optimisticThread, + ], + "updated_at", + new Set([ + scopedThreadKey(scopeThreadRef(optimisticThread.environmentId, optimisticThread.id)), + ]), + ).map((project) => project.projectKey), + ).toEqual(["logical-visible", "logical-optimistic"]); + }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a286e584fda..f2c085be192 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -32,8 +32,9 @@ type ScopedSidebarProject = SidebarProject & { }; type ScopedSidebarThread = ThreadSortInput & { - environmentId: string; - projectId: string; + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; archivedAt: string | null; }; @@ -882,6 +883,7 @@ export function sortLogicalProjectsForSidebar< projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, + optimisticallyArchivedThreadKeys: ReadonlySet = new Set(), ): TProject[] { const groupKeyByProjectRef = new Map( projects.flatMap((project) => @@ -893,7 +895,14 @@ export function sortLogicalProjectsForSidebar< ); const threadsByProjectKey = new Map(); for (const thread of threads) { - if (thread.archivedAt !== null) continue; + if ( + thread.archivedAt !== null || + optimisticallyArchivedThreadKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + ) { + continue; + } const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`); if (!projectKey) continue; const existing = threadsByProjectKey.get(projectKey); @@ -925,12 +934,18 @@ export function sortScopedProjectsForSidebar< projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, + optimisticallyArchivedThreadKeys: ReadonlySet = new Set(), ): TProject[] { const scopedKey = (environmentId: string, projectId: string) => `${environmentId}\u0000${projectId}`; const threadsByProject = new Map(); for (const thread of threads) { - if (thread.archivedAt !== null) { + if ( + thread.archivedAt !== null || + optimisticallyArchivedThreadKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + ) { continue; } const key = scopedKey(thread.environmentId, thread.projectId); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 2aebe5b5d69..591eb186747 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -815,6 +815,9 @@ export default function SidebarV2() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( + (state) => state.threadKeys, + ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -919,8 +922,14 @@ export default function SidebarV2() { ], ); const projectGroups = useMemo( - () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), - [sidebarProjectSortOrder, threads, unsortedProjectGroups], + () => + sortLogicalProjectsForSidebar( + unsortedProjectGroups, + threads, + sidebarProjectSortOrder, + optimisticallyArchivedThreadKeys, + ), + [optimisticallyArchivedThreadKeys, sidebarProjectSortOrder, threads, unsortedProjectGroups], ); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerEntryByInstanceId = useMemo( @@ -1181,9 +1190,6 @@ export default function SidebarV2() { // archive command is still in flight — because archive keeps its original // "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( - (state) => state.threadKeys, - ); const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; const visible = filterVisibleSidebarThreads(threads, optimisticallyArchivedThreadKeys).filter( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..49166b5af1d 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -13,6 +13,7 @@ import { } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { useOptimisticThreadArchiveStore } from "~/optimisticThreadArchiveStore"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, @@ -35,6 +36,9 @@ export function DraftHeroHeadline({ }: DraftHeroHeadlineProps) { const projects = useProjects(); const threads = useThreadShells(); + const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( + (state) => state.threadKeys, + ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -61,9 +65,11 @@ export function DraftHeroHeadline({ }), threads, projectSortOrder, + optimisticallyArchivedThreadKeys, ), [ environmentLabelById, + optimisticallyArchivedThreadKeys, primaryEnvironmentId, projectGroupingSettings, projectSortOrder, diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 6e8dbe33ff5..81cf10859e8 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { openCommandPalette } from "../commandPaletteBus"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; +import { useOptimisticThreadArchiveStore } from "../optimisticThreadArchiveStore"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; @@ -39,6 +40,9 @@ function ChatIndexRouteView() { function IndexDraftLanding() { const projects = useProjects(); const threads = useThreadShells(); + const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( + (state) => state.threadKeys, + ); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); const startingRef = useRef(false); @@ -47,9 +51,14 @@ function IndexDraftLanding() { const mostRecentProject = useMemo( () => bootstrapped - ? (sortScopedProjectsForSidebar(projects, threads, "updated_at")[0] ?? null) + ? (sortScopedProjectsForSidebar( + projects, + threads, + "updated_at", + optimisticallyArchivedThreadKeys, + )[0] ?? null) : null, - [bootstrapped, projects, threads], + [bootstrapped, optimisticallyArchivedThreadKeys, projects, threads], ); useEffect(() => { From 6e25770e223d58528ea2bc0a351c1742871a5f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 24 Jul 2026 00:38:07 +0100 Subject: [PATCH 48/63] Keep hidden archive threads out of palette actions - Reuse centralized thread visibility for palette navigation - Cover project selection with isolated optimistic archive playback --- BRANCH_DETAILS.md | 2 +- apps/web/src/components/CommandPalette.tsx | 44 ++++++++++++---------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index f77c1ecd1df..fafaad77923 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -30,7 +30,7 @@ Primary files: ## Sidebar And Shell Consistency -Sidebar archive visibility is centralized across persisted and optimistic archive states. Project rows, project status, sorting, keyboard navigation, and prewarming exclude an optimistically archived conversation immediately, so durable cold-storage work cannot leave a stale row or keyboard target visible while the server shell catches up. +Sidebar archive visibility is centralized across persisted and optimistic archive states. Project rows, project status, sorting, keyboard navigation, prewarming, and command-palette thread/project actions exclude an optimistically archived conversation immediately, so durable cold-storage work cannot leave a stale row or navigation target visible while the server shell catches up. Authoritative shell synchronization is shared runtime behavior in `packages/client-runtime/src/state/shell.ts`, `packages/client-runtime/src/rpc/client.ts`, and `apps/server/src/ws.ts`, rather than a branch-specific subscription implementation. HTTP snapshots provide an early shell, while completion-capable WebSocket sessions revalidate it with a socket-owned snapshot after live buffering begins; the same refresh runs when the app returns to the foreground. Cold archive storage relies on this contract because compacted per-thread history cannot prove that cached projects and threads still exist. diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 45cb688e6b5..30d6718f117 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -107,7 +107,11 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; -import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; +import { + filterVisibleSidebarThreads, + orderItemsByPreferredIds, + sortLogicalProjectsForSidebar, +} from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; @@ -505,6 +509,10 @@ function OpenCommandPaletteDialog(props: { const optimisticallyArchivedThreadKeys = useOptimisticThreadArchiveStore( (state) => state.threadKeys, ); + const visibleThreads = useMemo( + () => filterVisibleSidebarThreads(threads, optimisticallyArchivedThreadKeys), + [optimisticallyArchivedThreadKeys, threads], + ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); @@ -563,16 +571,10 @@ function OpenCommandPaletteDialog(props: { () => sortLogicalProjectsForSidebar( unsortedProjectGroups, - threads, + visibleThreads, clientSettings.sidebarProjectSortOrder, - optimisticallyArchivedThreadKeys, ), - [ - clientSettings.sidebarProjectSortOrder, - optimisticallyArchivedThreadKeys, - threads, - unsortedProjectGroups, - ], + [clientSettings.sidebarProjectSortOrder, unsortedProjectGroups, visibleThreads], ); const contextualProjectRef = useMemo( () => @@ -763,15 +765,13 @@ function OpenCommandPaletteDialog(props: { : null; const latestThread = groupedProjectKeys ? (sortThreads( - threads.filter( - (thread) => - thread.archivedAt === null && - groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), + visibleThreads.filter((thread) => + groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), ), clientSettings.sidebarThreadSortOrder, )[0] ?? null) : getLatestThreadForProject( - threads.filter((thread) => thread.environmentId === project.environmentId), + visibleThreads.filter((thread) => thread.environmentId === project.environmentId), project.id, clientSettings.sidebarThreadSortOrder, ); @@ -792,7 +792,7 @@ function OpenCommandPaletteDialog(props: { handleNewThread, navigate, projectGroupByTargetKey, - threads, + visibleThreads, ], ); @@ -875,7 +875,7 @@ function OpenCommandPaletteDialog(props: { const allThreadItems = useMemo( () => buildThreadActionItems({ - threads, + threads: visibleThreads, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, sortOrder: clientSettings.sidebarThreadSortOrder, @@ -889,7 +889,13 @@ function OpenCommandPaletteDialog(props: { }); }, }), - [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], + [ + activeThreadId, + clientSettings.sidebarThreadSortOrder, + navigate, + projectTitleById, + visibleThreads, + ], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -1330,7 +1336,7 @@ function OpenCommandPaletteDialog(props: { ); if (existing) { const latestThread = getLatestThreadForProject( - threads.filter((thread) => thread.environmentId === existing.environmentId), + visibleThreads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, clientSettings.sidebarThreadSortOrder, ); @@ -1419,7 +1425,7 @@ function OpenCommandPaletteDialog(props: { providers, setOpen, clientSettings.sidebarThreadSortOrder, - threads, + visibleThreads, ], ); From eb8fb79f0776ad3cc6c7e131e6ef511a92ba2c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 26 Jul 2026 17:18:54 +0100 Subject: [PATCH 49/63] Remove main-only pnpm global store pollution --- pnpm-lock.yaml | 103 ++++++++------------------------------------ pnpm-workspace.yaml | 8 +--- 2 files changed, 21 insertions(+), 90 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e932e28752..bea636d3807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= +packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -422,7 +422,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -477,7 +477,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -622,7 +622,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -688,7 +688,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) + version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -704,7 +704,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -732,7 +732,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -751,7 +751,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -764,7 +764,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -783,7 +783,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -805,7 +805,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -839,7 +839,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -864,7 +864,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -886,7 +886,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -917,7 +917,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2021,10 +2021,6 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 - vitest: '*' - peerDependenciesMeta: - vitest: - optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -11760,43 +11756,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - optionalDependencies: - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/browser-playwright' - - '@vitest/browser-webdriverio' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - msw - - publint - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -15335,7 +15297,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): + alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15349,7 +15311,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15382,39 +15344,12 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - '@types/node' - '@types/react' - - '@vitejs/devtools' - - '@vitest/browser-playwright' - - '@vitest/browser-webdriverio' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - msw - pg-native - - publint - react-devtools-core - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - utf-8-validate - - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3b4a7aaa975..ff840659efc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,8 +5,6 @@ packages: - packages/* - scripts -enableGlobalVirtualStore: true - # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -97,11 +95,9 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - # Wildcard semver excludes prereleases. - "@effect/vitest@4.0.0-beta.78": + "@effect/vitest@*": dependencies: - # The patched package imports vite-plus inside the shared graph. - vite-plus: 0.2.2 + vite-plus: "catalog:" peerDependenciesMeta: vitest: optional: true From 4ebfc2961445f0d4e76e81dd756950be1c25ae74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 26 Jul 2026 17:35:40 +0100 Subject: [PATCH 50/63] Fix archived thread action return type --- apps/mobile/src/features/home/useThreadListActions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index b5ea9ca3d16..1578d91e55f 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -230,7 +230,9 @@ export function useArchivedThreadListActions( ); const executeAction = useThreadActionExecutor(handleCompleted); const unarchiveThread = useCallback( - (thread: EnvironmentThreadShell) => executeAction("unarchive", thread), + async (thread: EnvironmentThreadShell) => { + await executeAction("unarchive", thread); + }, [executeAction], ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); From fc1d179e320114b641e062b27dc918547332686d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 26 Jul 2026 18:57:40 +0100 Subject: [PATCH 51/63] Require cold storage error causes - Preserve the underlying failure in every cold storage error --- apps/server/src/orchestration/Services/ThreadColdStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Services/ThreadColdStorage.ts b/apps/server/src/orchestration/Services/ThreadColdStorage.ts index 3c4b7b3f765..2c0313a8064 100644 --- a/apps/server/src/orchestration/Services/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Services/ThreadColdStorage.ts @@ -8,7 +8,7 @@ export class ThreadColdStorageError extends Schema.TaggedErrorClass Date: Mon, 27 Jul 2026 00:20:43 +0100 Subject: [PATCH 52/63] Stop retrying evicted thread subscriptions --- packages/client-runtime/src/rpc/client.ts | 8 ++++++- .../client-runtime/src/state/threadCache.ts | 8 +++++++ .../src/state/threads-sync.test.ts | 21 +++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 6 ++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c7c928b3c95..b99d05d9132 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -153,6 +153,9 @@ interface SubscriptionOptions { cause: Cause.Cause>, ) => Effect.Effect; readonly retryExpectedFailureAfter?: Duration.Input; + readonly shouldRetryExpectedFailure?: ( + cause: Cause.Cause>, + ) => boolean; readonly resubscribe?: Stream.Stream; } @@ -227,7 +230,10 @@ export function subscribeDynamic( const handled = Stream.fromEffect( options.onExpectedFailure(cause), ).pipe(Stream.drain); - if (options.retryExpectedFailureAfter === undefined) { + if ( + options.retryExpectedFailureAfter === undefined || + options.shouldRetryExpectedFailure?.(cause) === false + ) { return handled; } return handled.pipe( diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts index a23c0eca506..2267bbef48a 100644 --- a/packages/client-runtime/src/state/threadCache.ts +++ b/packages/client-runtime/src/state/threadCache.ts @@ -128,6 +128,14 @@ export function cachedThreadGeneration( return existingThreadCacheState(cache, environmentId, threadId)?.generation ?? 0; } +export function isCachedThreadEvicted( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +): boolean { + return existingThreadCacheState(cache, environmentId, threadId)?.evicted === true; +} + export const persistCachedThread = Effect.fn("EnvironmentThreadCache.persist")(function* ( cache: EnvironmentCacheStore["Service"], environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 58035a3c0bb..3388dcb0652 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -527,6 +527,27 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("does not retry a missing thread after archive eviction", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, archived()); + yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.archivedAt !== null, + ); + + yield* Queue.offer(harness.inputs, new Error("thread was moved to cold storage")); + yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.error)); + yield* TestClock.adjust("250 millis"); + for (let attempt = 0; attempt < 10; attempt += 1) { + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + }), + ); + it.effect("removes cached data when an archived thread arrives in a snapshot", () => Effect.gen(function* () { const archivedThread = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 096d2ca4978..839baabb176 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -25,6 +25,7 @@ import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; import { cachedThreadGeneration, evictCachedThread, + isCachedThreadEvicted, persistCachedThread, retainCachedThread, reviveCachedThread, @@ -306,6 +307,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make { onExpectedFailure: setStreamError, retryExpectedFailureAfter: "250 millis", + // A retained route may outlive an archive navigation for several + // minutes. Once archive acknowledgement evicts the detail cache, a + // missing cold thread is terminal for that retained subscription + // rather than a transient materialization race. + shouldRetryExpectedFailure: () => !isCachedThreadEvicted(cache, environmentId, threadId), resubscribe: foregroundResubscriptions, }, ).pipe(Stream.runForEach(applyItem)), From ba3ec7cafba7928dec062b5abd7e47c8897bede1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 27 Jul 2026 15:13:47 +0100 Subject: [PATCH 53/63] Clean up previews across thread lifecycle --- BRANCH_DETAILS.md | 2 + apps/server/src/server.test.ts | 50 ++++++++++++++++++- apps/server/src/ws.ts | 14 ++++++ apps/web/src/browser/ElectronBrowserHost.tsx | 24 ++++++++- .../browser/previewThreadLifecycle.test.ts | 33 ++++++++++++ .../web/src/browser/previewThreadLifecycle.ts | 21 ++++++++ apps/web/src/previewMiniPlayerStore.test.ts | 14 ++++++ 7 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/browser/previewThreadLifecycle.test.ts create mode 100644 apps/web/src/browser/previewThreadLifecycle.ts diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index fafaad77923..ed67011e656 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -38,6 +38,8 @@ Persisted web and mobile thread details are also fast-paint caches, not an archi Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. +`apps/server/src/ws.ts` closes every server preview session for a thread after its archive or delete command commits. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to detect when an authoritative shell transition removes a previously active thread, then clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/server.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. + `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. Mobile Archive rows omit invalid lifecycle timestamps instead of presenting corrupt or legacy values as newly archived, and sort missing or invalid archive dates after valid dates in both sort directions. General mobile time rendering is unchanged. diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3d5dd462c11..2dd79fe1ea0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -338,6 +338,7 @@ const buildAppUnderTest = (options?: { ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; terminalManager?: Partial; + previewManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; @@ -672,6 +673,7 @@ const buildAppUnderTest = (options?: { subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), + ...options?.layers?.previewManager, }), Layer.mock(PortScanner.PortDiscovery)({ scan: () => Effect.succeed([]), @@ -6412,7 +6414,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session and closes thread terminals after archive", () => + it.effect("stops the provider session and closes thread runtimes after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); const effects: string[] = []; @@ -6427,6 +6429,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { effects.push(`terminal.close:${input.threadId}`); }), }, + previewManager: { + close: (input) => + Effect.sync(() => { + effects.push(`preview.close:${input.threadId}`); + }), + }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -6474,6 +6482,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "dispatch:thread.archive", "dispatch:thread.session.stop", `terminal.close:${threadId}`, + `preview.close:${threadId}`, ]); const sessionStopCommand = dispatchedCommands[1]; assert.equal(sessionStopCommand?.type, "thread.session.stop"); @@ -6483,6 +6492,45 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("closes thread previews after deletion", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-delete-preview"); + const effects: string[] = []; + + yield* buildAppUnderTest({ + layers: { + previewManager: { + close: (input) => + Effect.sync(() => { + effects.push(`preview.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + effects.push(`dispatch:${command.type}`); + return { sequence: 1 }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.delete", + commandId: CommandId.make("cmd-thread-delete-preview"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.delete", `preview.close:${threadId}`]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("checks session status before archiving removes the thread from active lookups", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-precheck"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b8f4b07124d..dba69301582 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1163,6 +1163,20 @@ const makeWsRpcLayer = ( ), ); } + if ( + normalizedCommand.type === "thread.archive" || + normalizedCommand.type === "thread.delete" + ) { + yield* previewManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread previews after lifecycle command", { + commandType: normalizedCommand.type, + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } return result; }).pipe( Effect.mapError((cause) => diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 51fa73a721f..59846e7c85f 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -2,19 +2,25 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; -import { useActivePreviewSessions } from "~/previewStateStore"; +import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { removePreviewThread, useActivePreviewSessions } from "~/previewStateStore"; +import { useThreadRefs } from "~/state/entities"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; +import { collectRemovedPreviewThreadRefs } from "./previewThreadLifecycle"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); const previewByThreadKey = useActivePreviewSessions(); + const activeThreadRefs = useThreadRefs(); + const miniPlayerByThreadKey = usePreviewMiniPlayerStore((state) => state.byThreadKey); + const previousActiveThreadRefs = useRef(activeThreadRefs); const sessions = useMemo( () => Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { @@ -30,6 +36,20 @@ export function ElectronBrowserHost() { [previewByThreadKey], ); + useEffect(() => { + const removedThreadRefs = collectRemovedPreviewThreadRefs({ + previousActiveThreadRefs: previousActiveThreadRefs.current, + activeThreadRefs, + previewThreadKeys: Object.keys(previewByThreadKey), + miniPlayerThreadKeys: Object.keys(miniPlayerByThreadKey), + }); + previousActiveThreadRefs.current = activeThreadRefs; + for (const threadRef of removedThreadRefs) { + removePreviewThread(threadRef); + usePreviewMiniPlayerStore.getState().removeThread(threadRef); + } + }, [activeThreadRefs, miniPlayerByThreadKey, previewByThreadKey]); + useEffect(() => { const preview = window.desktopBridge?.preview; if (!preview) return; diff --git a/apps/web/src/browser/previewThreadLifecycle.test.ts b/apps/web/src/browser/previewThreadLifecycle.test.ts new file mode 100644 index 00000000000..b391730d228 --- /dev/null +++ b/apps/web/src/browser/previewThreadLifecycle.test.ts @@ -0,0 +1,33 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { collectRemovedPreviewThreadRefs } from "./previewThreadLifecycle"; + +const activeRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-active")); +const archivedRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-archived")); +const deletedRef = scopeThreadRef("env-2" as EnvironmentId, ThreadId.make("thread-deleted")); + +describe("collectRemovedPreviewThreadRefs", () => { + it("finds archived and deleted preview state across an authoritative shell transition", () => { + expect( + collectRemovedPreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, archivedRef, deletedRef], + activeThreadRefs: [activeRef], + previewThreadKeys: [scopedThreadKey(activeRef), scopedThreadKey(archivedRef)], + miniPlayerThreadKeys: [scopedThreadKey(archivedRef), scopedThreadKey(deletedRef)], + }), + ).toEqual([archivedRef, deletedRef]); + }); + + it("does not clean removed threads without preview lifecycle state", () => { + expect( + collectRemovedPreviewThreadRefs({ + previousActiveThreadRefs: [archivedRef], + activeThreadRefs: [], + previewThreadKeys: [], + miniPlayerThreadKeys: [], + }), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/browser/previewThreadLifecycle.ts b/apps/web/src/browser/previewThreadLifecycle.ts new file mode 100644 index 00000000000..cb8be51276f --- /dev/null +++ b/apps/web/src/browser/previewThreadLifecycle.ts @@ -0,0 +1,21 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +interface CollectRemovedPreviewThreadRefsInput { + readonly previousActiveThreadRefs: readonly ScopedThreadRef[]; + readonly activeThreadRefs: readonly ScopedThreadRef[]; + readonly previewThreadKeys: Iterable; + readonly miniPlayerThreadKeys: Iterable; +} + +export function collectRemovedPreviewThreadRefs( + input: CollectRemovedPreviewThreadRefsInput, +): ScopedThreadRef[] { + const activeThreadKeys = new Set(input.activeThreadRefs.map(scopedThreadKey)); + const previewThreadKeys = new Set([...input.previewThreadKeys, ...input.miniPlayerThreadKeys]); + + return input.previousActiveThreadRefs.filter((threadRef) => { + const threadKey = scopedThreadKey(threadRef); + return !activeThreadKeys.has(threadKey) && previewThreadKeys.has(threadKey); + }); +} diff --git a/apps/web/src/previewMiniPlayerStore.test.ts b/apps/web/src/previewMiniPlayerStore.test.ts index d6ec64bf069..0af4a77af2e 100644 --- a/apps/web/src/previewMiniPlayerStore.test.ts +++ b/apps/web/src/previewMiniPlayerStore.test.ts @@ -61,4 +61,18 @@ describe("previewMiniPlayerStore", () => { selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), ).toMatchObject({ tabId: "tab-b", size: { width: 480, height: 320 } }); }); + + it("removes only the targeted thread lifecycle state", () => { + usePreviewMiniPlayerStore.getState().open(refA, "tab-a"); + usePreviewMiniPlayerStore.getState().open(refB, "tab-b"); + + usePreviewMiniPlayerStore.getState().removeThread(refA); + + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), + ).toBeNull(); + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB), + ).toMatchObject({ tabId: "tab-b" }); + }); }); From 6f8e371b49663dc9fa316984c7a14b7fffe258b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 27 Jul 2026 16:18:38 +0100 Subject: [PATCH 54/63] Close previews from thread lifecycle events - Cover project-expanded thread deletions - Clear renderer state after preview-close races --- BRANCH_DETAILS.md | 2 +- .../Layers/ThreadDeletionReactor.test.ts | 15 ++++++ .../Layers/ThreadDeletionReactor.ts | 21 +++++++- apps/server/src/server.test.ts | 50 +------------------ apps/server/src/ws.ts | 14 ------ apps/web/src/browser/ElectronBrowserHost.tsx | 5 +- .../browser/previewThreadLifecycle.test.ts | 10 ++-- .../web/src/browser/previewThreadLifecycle.ts | 11 ++-- 8 files changed, 43 insertions(+), 85 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index ed67011e656..9bd86141084 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -38,7 +38,7 @@ Persisted web and mobile thread details are also fast-paint caches, not an archi Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. -`apps/server/src/ws.ts` closes every server preview session for a thread after its archive or delete command commits. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to detect when an authoritative shell transition removes a previously active thread, then clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/server.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. +`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` closes every server preview session when it observes a thread archive or deletion event, including deletions expanded from a project command. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to detect when an authoritative shell transition removes a previously active thread, then clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 1562da40270..95290efc29e 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -32,6 +32,7 @@ import { ProviderValidationError } from "../../provider/Errors.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { PreviewManager } from "../../preview/Manager.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; @@ -97,6 +98,11 @@ function testReactorLayer(input: { close: () => Effect.void, }), ), + Layer.provide( + Layer.mock(PreviewManager)({ + close: () => Effect.void, + }), + ), Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provide( Layer.mock(ThreadColdStorage)({ @@ -345,6 +351,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa const threadId = ThreadId.make("thread-force-delete-cold"); const commandId = CommandId.make("command-force-delete-cold"); const deleteStarted = yield* Deferred.make(); + const previewClosed = yield* Deferred.make(); const orchestrationEngineLayer = Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -358,6 +365,12 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa const terminalLayer = Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void, }); + const previewLayer = Layer.mock(PreviewManager)({ + close: (input) => + Effect.sync(() => { + assert.equal(input.threadId, threadId); + }).pipe(Effect.andThen(Deferred.succeed(previewClosed, undefined)), Effect.asVoid), + }); const loggerLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); const coldStorageLayer = ThreadColdStorageLive.pipe( Layer.provideMerge(SqlitePersistenceMemory), @@ -380,6 +393,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa }), ), Layer.provide(terminalLayer), + Layer.provide(previewLayer), Layer.provide(loggerLayer), Layer.provideMerge(coldStorageLayer), ); @@ -498,6 +512,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa yield* reactor.start(); yield* PubSub.publish(events, { ...deletedEvent, sequence: 4 }); + yield* Deferred.await(previewClosed); yield* Deferred.await(deleteStarted); yield* reactor.drain; diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 32ef4410b5d..b27c9bd9edb 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -12,6 +12,7 @@ import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { PreviewManager } from "../../preview/Manager.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; @@ -78,6 +79,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const providerSessionDirectory = yield* ProviderSessionDirectory; const projectionThreadSessions = yield* ProjectionThreadSessionRepository; + const previewManager = yield* PreviewManager; const terminalManager = yield* TerminalManager.TerminalManager; const threadColdStorage = yield* ThreadColdStorage; const providerEventLoggers = yield* ProviderEventLoggers; @@ -97,6 +99,13 @@ const make = Effect.gen(function* () { threadId, }); + const closeThreadPreviews = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: previewManager.close({ threadId }), + message: "thread lifecycle cleanup skipped preview close", + threadId, + }); + const closeProviderLogWritersRequired = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => Effect.all( [providerEventLoggers.native, providerEventLoggers.canonical].flatMap((logger) => @@ -217,10 +226,18 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if (event.type === "thread.deleted") { - return enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }); + return closeThreadPreviews(event.payload.threadId).pipe( + Effect.andThen( + enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }), + ), + ); } if (event.type === "thread.archived") { - return enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }); + return closeThreadPreviews(event.payload.threadId).pipe( + Effect.andThen( + enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }), + ), + ); } return Effect.void; }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2dd79fe1ea0..3d5dd462c11 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -338,7 +338,6 @@ const buildAppUnderTest = (options?: { ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; terminalManager?: Partial; - previewManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; @@ -673,7 +672,6 @@ const buildAppUnderTest = (options?: { subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), - ...options?.layers?.previewManager, }), Layer.mock(PortScanner.PortDiscovery)({ scan: () => Effect.succeed([]), @@ -6414,7 +6412,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session and closes thread runtimes after archive", () => + it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); const effects: string[] = []; @@ -6429,12 +6427,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { effects.push(`terminal.close:${input.threadId}`); }), }, - previewManager: { - close: (input) => - Effect.sync(() => { - effects.push(`preview.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -6482,7 +6474,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "dispatch:thread.archive", "dispatch:thread.session.stop", `terminal.close:${threadId}`, - `preview.close:${threadId}`, ]); const sessionStopCommand = dispatchedCommands[1]; assert.equal(sessionStopCommand?.type, "thread.session.stop"); @@ -6492,45 +6483,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("closes thread previews after deletion", () => - Effect.gen(function* () { - const threadId = ThreadId.make("thread-delete-preview"); - const effects: string[] = []; - - yield* buildAppUnderTest({ - layers: { - previewManager: { - close: (input) => - Effect.sync(() => { - effects.push(`preview.close:${input.threadId}`); - }), - }, - orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - effects.push(`dispatch:${command.type}`); - return { sequence: 1 }; - }), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.delete", - commandId: CommandId.make("cmd-thread-delete-preview"), - threadId, - }), - ), - ); - - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.delete", `preview.close:${threadId}`]); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("checks session status before archiving removes the thread from active lookups", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-precheck"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dba69301582..b8f4b07124d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1163,20 +1163,6 @@ const makeWsRpcLayer = ( ), ); } - if ( - normalizedCommand.type === "thread.archive" || - normalizedCommand.type === "thread.delete" - ) { - yield* previewManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread previews after lifecycle command", { - commandType: normalizedCommand.type, - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); - } return result; }).pipe( Effect.mapError((cause) => diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 59846e7c85f..5eb226e100a 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -19,7 +19,6 @@ export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); const previewByThreadKey = useActivePreviewSessions(); const activeThreadRefs = useThreadRefs(); - const miniPlayerByThreadKey = usePreviewMiniPlayerStore((state) => state.byThreadKey); const previousActiveThreadRefs = useRef(activeThreadRefs); const sessions = useMemo( () => @@ -40,15 +39,13 @@ export function ElectronBrowserHost() { const removedThreadRefs = collectRemovedPreviewThreadRefs({ previousActiveThreadRefs: previousActiveThreadRefs.current, activeThreadRefs, - previewThreadKeys: Object.keys(previewByThreadKey), - miniPlayerThreadKeys: Object.keys(miniPlayerByThreadKey), }); previousActiveThreadRefs.current = activeThreadRefs; for (const threadRef of removedThreadRefs) { removePreviewThread(threadRef); usePreviewMiniPlayerStore.getState().removeThread(threadRef); } - }, [activeThreadRefs, miniPlayerByThreadKey, previewByThreadKey]); + }, [activeThreadRefs]); useEffect(() => { const preview = window.desktopBridge?.preview; diff --git a/apps/web/src/browser/previewThreadLifecycle.test.ts b/apps/web/src/browser/previewThreadLifecycle.test.ts index b391730d228..5ad105515bb 100644 --- a/apps/web/src/browser/previewThreadLifecycle.test.ts +++ b/apps/web/src/browser/previewThreadLifecycle.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -14,20 +14,16 @@ describe("collectRemovedPreviewThreadRefs", () => { collectRemovedPreviewThreadRefs({ previousActiveThreadRefs: [activeRef, archivedRef, deletedRef], activeThreadRefs: [activeRef], - previewThreadKeys: [scopedThreadKey(activeRef), scopedThreadKey(archivedRef)], - miniPlayerThreadKeys: [scopedThreadKey(archivedRef), scopedThreadKey(deletedRef)], }), ).toEqual([archivedRef, deletedRef]); }); - it("does not clean removed threads without preview lifecycle state", () => { + it("cleans removed threads after preview sessions close first", () => { expect( collectRemovedPreviewThreadRefs({ previousActiveThreadRefs: [archivedRef], activeThreadRefs: [], - previewThreadKeys: [], - miniPlayerThreadKeys: [], }), - ).toEqual([]); + ).toEqual([archivedRef]); }); }); diff --git a/apps/web/src/browser/previewThreadLifecycle.ts b/apps/web/src/browser/previewThreadLifecycle.ts index cb8be51276f..f7a33ff4385 100644 --- a/apps/web/src/browser/previewThreadLifecycle.ts +++ b/apps/web/src/browser/previewThreadLifecycle.ts @@ -4,18 +4,13 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; interface CollectRemovedPreviewThreadRefsInput { readonly previousActiveThreadRefs: readonly ScopedThreadRef[]; readonly activeThreadRefs: readonly ScopedThreadRef[]; - readonly previewThreadKeys: Iterable; - readonly miniPlayerThreadKeys: Iterable; } export function collectRemovedPreviewThreadRefs( input: CollectRemovedPreviewThreadRefsInput, ): ScopedThreadRef[] { const activeThreadKeys = new Set(input.activeThreadRefs.map(scopedThreadKey)); - const previewThreadKeys = new Set([...input.previewThreadKeys, ...input.miniPlayerThreadKeys]); - - return input.previousActiveThreadRefs.filter((threadRef) => { - const threadKey = scopedThreadKey(threadRef); - return !activeThreadKeys.has(threadKey) && previewThreadKeys.has(threadKey); - }); + return input.previousActiveThreadRefs.filter( + (threadRef) => !activeThreadKeys.has(scopedThreadKey(threadRef)), + ); } From 92426d2c14352cca99c209d7d7d0799b01e0892d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 27 Jul 2026 16:29:25 +0100 Subject: [PATCH 55/63] Protect preview cleanup across shell synchronization - Queue preview shutdown with durable archive and delete work - Gate renderer cleanup on live per-environment shell state - Cover archive failures and synchronization transitions --- BRANCH_DETAILS.md | 2 +- .../Layers/ThreadDeletionReactor.test.ts | 52 ++++++++++++++--- .../Layers/ThreadDeletionReactor.ts | 13 +---- apps/web/src/browser/ElectronBrowserHost.tsx | 14 +++-- .../browser/previewThreadLifecycle.test.ts | 58 ++++++++++++++----- .../web/src/browser/previewThreadLifecycle.ts | 31 +++++++--- apps/web/src/state/entities.ts | 6 +- apps/web/src/state/shell.ts | 19 ++++++ 8 files changed, 149 insertions(+), 46 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 9bd86141084..1e9aa9ca89c 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -38,7 +38,7 @@ Persisted web and mobile thread details are also fast-paint caches, not an archi Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. -`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` closes every server preview session when it observes a thread archive or deletion event, including deletions expanded from a project command. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to detect when an authoritative shell transition removes a previously active thread, then clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. +`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` closes every server preview session when it observes a thread archive or deletion event, including deletions expanded from a project command. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 95290efc29e..d3cb126e47c 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, + PreviewSessionLookupError, ProjectId, ProviderInstanceId, ThreadId, @@ -71,6 +72,7 @@ function testReactorLayer(input: { readonly getBinding: ProviderSessionDirectory["Service"]["getBinding"]; readonly getProjectedSession: ProjectionThreadSessionRepository["Service"]["getByThreadId"]; readonly archiveThread: ThreadColdStorage["Service"]["archiveThread"]; + readonly closePreview?: PreviewManager["Service"]["close"]; readonly pendingArchives?: ReadonlyArray; }) { return ThreadDeletionReactorLive.pipe( @@ -100,7 +102,7 @@ function testReactorLayer(input: { ), Layer.provide( Layer.mock(PreviewManager)({ - close: () => Effect.void, + close: input.closePreview ?? (() => Effect.void), }), ), Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), @@ -194,6 +196,44 @@ effectIt.effect("archives a settled thread when its provider binding is already }), ); +effectIt.effect("closes previews before archiving a thread lifecycle job", () => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const subscription = yield* PubSub.subscribe(events); + const threadId = ThreadId.make("thread-archive-preview"); + const archived = yield* Deferred.make(); + const previewCloseCalls = yield* Ref.make>([]); + const layer = testReactorLayer({ + eventStream: Stream.fromSubscription(subscription), + stopSession: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + getProjectedSession: () => Effect.succeed(Option.none()), + closePreview: ({ threadId: closedThreadId }) => + Ref.update(previewCloseCalls, (calls) => [...calls, closedThreadId]).pipe( + Effect.andThen( + Effect.fail( + new PreviewSessionLookupError({ + threadId: closedThreadId, + tabId: "missing-preview", + }), + ), + ), + ), + archiveThread: () => Deferred.succeed(archived, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* PubSub.publish(events, archivedEvent(threadId)); + yield* Deferred.await(archived); + yield* reactor.drain; + + expect(yield* Ref.get(previewCloseCalls)).toEqual([threadId]); + }).pipe(Effect.provide(layer)); + }), +); + effectIt.effect( "keeps archive cleanup fail-closed without a binding for an active projection", () => @@ -351,7 +391,7 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa const threadId = ThreadId.make("thread-force-delete-cold"); const commandId = CommandId.make("command-force-delete-cold"); const deleteStarted = yield* Deferred.make(); - const previewClosed = yield* Deferred.make(); + const previewCloseCalls = yield* Ref.make>([]); const orchestrationEngineLayer = Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -366,10 +406,8 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa close: () => Effect.void, }); const previewLayer = Layer.mock(PreviewManager)({ - close: (input) => - Effect.sync(() => { - assert.equal(input.threadId, threadId); - }).pipe(Effect.andThen(Deferred.succeed(previewClosed, undefined)), Effect.asVoid), + close: ({ threadId: closedThreadId }) => + Ref.update(previewCloseCalls, (calls) => [...calls, closedThreadId]), }); const loggerLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); const coldStorageLayer = ThreadColdStorageLive.pipe( @@ -512,9 +550,9 @@ effectIt.effect("force-deleting a project removes an already-cold archived threa yield* reactor.start(); yield* PubSub.publish(events, { ...deletedEvent, sequence: 4 }); - yield* Deferred.await(previewClosed); yield* Deferred.await(deleteStarted); yield* reactor.drain; + expect(yield* Ref.get(previewCloseCalls)).toEqual([threadId]); const hotRows = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count FROM projection_threads WHERE thread_id = ${threadId} diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index b27c9bd9edb..f4f60e8045f 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -153,6 +153,7 @@ const make = Effect.gen(function* () { return; } const { threadId } = job; + yield* closeThreadPreviews(threadId); if (job.type === "archive") { // Archiving must not snapshot or delete hot rows while any active writer // can still mutate them. A failure leaves the durable archived shell or @@ -226,18 +227,10 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if (event.type === "thread.deleted") { - return closeThreadPreviews(event.payload.threadId).pipe( - Effect.andThen( - enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }), - ), - ); + return enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }); } if (event.type === "thread.archived") { - return closeThreadPreviews(event.payload.threadId).pipe( - Effect.andThen( - enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }), - ), - ); + return enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }); } return Effect.void; }), diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 5eb226e100a..652508d5523 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -8,17 +8,18 @@ import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { removePreviewThread, useActivePreviewSessions } from "~/previewStateStore"; -import { useThreadRefs } from "~/state/entities"; +import { useLiveEnvironmentIds, useThreadRefs } from "~/state/entities"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; -import { collectRemovedPreviewThreadRefs } from "./previewThreadLifecycle"; +import { reconcilePreviewThreadRefs } from "./previewThreadLifecycle"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); const previewByThreadKey = useActivePreviewSessions(); const activeThreadRefs = useThreadRefs(); + const liveEnvironmentIds = useLiveEnvironmentIds(); const previousActiveThreadRefs = useRef(activeThreadRefs); const sessions = useMemo( () => @@ -36,16 +37,17 @@ export function ElectronBrowserHost() { ); useEffect(() => { - const removedThreadRefs = collectRemovedPreviewThreadRefs({ + const reconciliation = reconcilePreviewThreadRefs({ previousActiveThreadRefs: previousActiveThreadRefs.current, activeThreadRefs, + liveEnvironmentIds, }); - previousActiveThreadRefs.current = activeThreadRefs; - for (const threadRef of removedThreadRefs) { + previousActiveThreadRefs.current = reconciliation.nextActiveThreadRefs; + for (const threadRef of reconciliation.removedThreadRefs) { removePreviewThread(threadRef); usePreviewMiniPlayerStore.getState().removeThread(threadRef); } - }, [activeThreadRefs]); + }, [activeThreadRefs, liveEnvironmentIds]); useEffect(() => { const preview = window.desktopBridge?.preview; diff --git a/apps/web/src/browser/previewThreadLifecycle.test.ts b/apps/web/src/browser/previewThreadLifecycle.test.ts index 5ad105515bb..4c4d28c856c 100644 --- a/apps/web/src/browser/previewThreadLifecycle.test.ts +++ b/apps/web/src/browser/previewThreadLifecycle.test.ts @@ -1,29 +1,59 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { collectRemovedPreviewThreadRefs } from "./previewThreadLifecycle"; +import { reconcilePreviewThreadRefs } from "./previewThreadLifecycle"; -const activeRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-active")); -const archivedRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-archived")); -const deletedRef = scopeThreadRef("env-2" as EnvironmentId, ThreadId.make("thread-deleted")); +const environmentOne = EnvironmentId.make("env-1"); +const environmentTwo = EnvironmentId.make("env-2"); +const activeRef = scopeThreadRef(environmentOne, ThreadId.make("thread-active")); +const archivedRef = scopeThreadRef(environmentOne, ThreadId.make("thread-archived")); +const deletedRef = scopeThreadRef(environmentTwo, ThreadId.make("thread-deleted")); -describe("collectRemovedPreviewThreadRefs", () => { - it("finds archived and deleted preview state across an authoritative shell transition", () => { +describe("reconcilePreviewThreadRefs", () => { + it("finds removed preview state only in live environment shells", () => { expect( - collectRemovedPreviewThreadRefs({ + reconcilePreviewThreadRefs({ previousActiveThreadRefs: [activeRef, archivedRef, deletedRef], activeThreadRefs: [activeRef], + liveEnvironmentIds: new Set([environmentOne]), }), - ).toEqual([archivedRef, deletedRef]); + ).toEqual({ + removedThreadRefs: [archivedRef], + nextActiveThreadRefs: [deletedRef, activeRef], + }); }); - it("cleans removed threads after preview sessions close first", () => { + it("retains the prior baseline until shell synchronization completes", () => { + const synchronizing = reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, archivedRef], + activeThreadRefs: [activeRef], + liveEnvironmentIds: new Set(), + }); + expect(synchronizing).toEqual({ + removedThreadRefs: [], + nextActiveThreadRefs: [activeRef, archivedRef], + }); + expect( - collectRemovedPreviewThreadRefs({ - previousActiveThreadRefs: [archivedRef], - activeThreadRefs: [], + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: synchronizing.nextActiveThreadRefs, + activeThreadRefs: [activeRef], + liveEnvironmentIds: new Set([environmentOne]), }), - ).toEqual([archivedRef]); + ).toEqual({ + removedThreadRefs: [archivedRef], + nextActiveThreadRefs: [activeRef], + }); + }); + + it("does not report unchanged reordered thread references", () => { + expect( + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, archivedRef], + activeThreadRefs: [{ ...archivedRef }, { ...activeRef }], + liveEnvironmentIds: new Set([environmentOne]), + }).removedThreadRefs, + ).toEqual([]); }); }); diff --git a/apps/web/src/browser/previewThreadLifecycle.ts b/apps/web/src/browser/previewThreadLifecycle.ts index f7a33ff4385..f3137b5f130 100644 --- a/apps/web/src/browser/previewThreadLifecycle.ts +++ b/apps/web/src/browser/previewThreadLifecycle.ts @@ -1,16 +1,33 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -interface CollectRemovedPreviewThreadRefsInput { +interface ReconcilePreviewThreadRefsInput { readonly previousActiveThreadRefs: readonly ScopedThreadRef[]; readonly activeThreadRefs: readonly ScopedThreadRef[]; + readonly liveEnvironmentIds: ReadonlySet; } -export function collectRemovedPreviewThreadRefs( - input: CollectRemovedPreviewThreadRefsInput, -): ScopedThreadRef[] { +interface ReconcilePreviewThreadRefsResult { + readonly removedThreadRefs: readonly ScopedThreadRef[]; + readonly nextActiveThreadRefs: readonly ScopedThreadRef[]; +} + +export function reconcilePreviewThreadRefs( + input: ReconcilePreviewThreadRefsInput, +): ReconcilePreviewThreadRefsResult { const activeThreadKeys = new Set(input.activeThreadRefs.map(scopedThreadKey)); - return input.previousActiveThreadRefs.filter( - (threadRef) => !activeThreadKeys.has(scopedThreadKey(threadRef)), + const removedThreadRefs = input.previousActiveThreadRefs.filter( + (threadRef) => + input.liveEnvironmentIds.has(threadRef.environmentId) && + !activeThreadKeys.has(scopedThreadKey(threadRef)), ); + const nextActiveThreadRefs = [ + ...input.previousActiveThreadRefs.filter( + (threadRef) => !input.liveEnvironmentIds.has(threadRef.environmentId), + ), + ...input.activeThreadRefs.filter((threadRef) => + input.liveEnvironmentIds.has(threadRef.environmentId), + ), + ]; + return { removedThreadRefs, nextActiveThreadRefs }; } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 9bd20070c23..ba966f339a3 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -23,7 +23,7 @@ import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom } from "./server"; -import { allEnvironmentShellsBootstrappedAtom } from "./shell"; +import { allEnvironmentShellsBootstrappedAtom, liveEnvironmentIdsAtom } from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); @@ -124,6 +124,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } +export function useLiveEnvironmentIds(): ReadonlySet { + return useAtomValue(liveEnvironmentIdsAtom); +} + export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index dfb104e5c99..989cf09095e 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -8,6 +8,7 @@ import { createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -22,6 +23,24 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ shellStateValueAtom: environmentShell.stateValueAtom, }); +let previousLiveEnvironmentIds: ReadonlySet = new Set(); +export const liveEnvironmentIdsAtom = Atom.make((get) => { + const next = new Set(); + for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { + if (get(environmentShell.stateValueAtom(environmentId)).status === "live") { + next.add(environmentId); + } + } + if ( + previousLiveEnvironmentIds.size === next.size && + [...previousLiveEnvironmentIds].every((environmentId) => next.has(environmentId)) + ) { + return previousLiveEnvironmentIds; + } + previousLiveEnvironmentIds = next; + return previousLiveEnvironmentIds; +}).pipe(Atom.withLabel("web-live-environment-ids")); + export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); if (Option.isNone(catalog)) { From 14292a3663a472c1f5f923b00521f2ca699f4650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 27 Jul 2026 16:35:19 +0100 Subject: [PATCH 56/63] Clarify queued preview lifecycle cleanup - Describe event observation as scheduling lifecycle work - Preserve best-effort preview shutdown semantics --- BRANCH_DETAILS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 1e9aa9ca89c..1f24fc2ca3e 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -38,7 +38,7 @@ Persisted web and mobile thread details are also fast-paint caches, not an archi Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. -`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` closes every server preview session when it observes a thread archive or deletion event, including deletions expanded from a project command. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. +`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` schedules lifecycle cleanup when it observes a thread archive or deletion event, including deletions expanded from a project command; that cleanup makes a best-effort attempt to close every server preview session. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. From 587d386fd1f79272f38d19bda106e8db5a4c0186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 27 Jul 2026 16:44:52 +0100 Subject: [PATCH 57/63] Fix preview cleanup lifecycle races - Drop renderer refs for removed environments - Close archived previews before queued lifecycle work - Cover catalog removal and delayed archive regressions --- BRANCH_DETAILS.md | 2 +- .../Layers/ThreadDeletionReactor.test.ts | 58 ++++++++++++++++++- .../Layers/ThreadDeletionReactor.ts | 13 ++++- apps/web/src/browser/ElectronBrowserHost.tsx | 6 +- .../browser/previewThreadLifecycle.test.ts | 32 ++++++++++ .../web/src/browser/previewThreadLifecycle.ts | 18 ++++-- apps/web/src/state/environments.ts | 2 + 7 files changed, 120 insertions(+), 11 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 1f24fc2ca3e..8ec8e319af3 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -38,7 +38,7 @@ Persisted web and mobile thread details are also fast-paint caches, not an archi Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. -`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` schedules lifecycle cleanup when it observes a thread archive or deletion event, including deletions expanded from a project command; that cleanup makes a best-effort attempt to close every server preview session. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. +`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` schedules lifecycle cleanup when it observes a thread archive or deletion event, including deletions expanded from a project command; it makes a best-effort attempt to close every server preview session at event observation time so delayed archive work cannot close a preview reopened after unarchive. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live or discard the baseline when the environment leaves the catalog. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index d3cb126e47c..fdd2e1c60f2 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -196,7 +196,7 @@ effectIt.effect("archives a settled thread when its provider binding is already }), ); -effectIt.effect("closes previews before archiving a thread lifecycle job", () => +effectIt.effect("closes previews when observing an archive event", () => Effect.gen(function* () { const events = yield* PubSub.unbounded(); const subscription = yield* PubSub.subscribe(events); @@ -234,6 +234,62 @@ effectIt.effect("closes previews before archiving a thread lifecycle job", () => }), ); +effectIt.effect("does not close a restored preview from a delayed archive job", () => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const subscription = yield* PubSub.subscribe(events); + const blockingThreadId = ThreadId.make("thread-blocking-archive"); + const restoredThreadId = ThreadId.make("thread-restored-before-archive-job"); + const blockingArchiveStarted = yield* Deferred.make(); + const releaseBlockingArchive = yield* Deferred.make(); + const restoredPreviewClosed = yield* Deferred.make(); + const restoredArchiveStarted = yield* Deferred.make(); + const restored = yield* Ref.make(false); + const latePreviewCloseCalls = yield* Ref.make(0); + const layer = testReactorLayer({ + eventStream: Stream.fromSubscription(subscription), + stopSession: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + getProjectedSession: () => Effect.succeed(Option.none()), + closePreview: ({ threadId }) => + Ref.get(restored).pipe( + Effect.flatMap((isRestored) => + threadId === restoredThreadId && isRestored + ? Ref.update(latePreviewCloseCalls, (count) => count + 1) + : Effect.void, + ), + Effect.andThen( + threadId === restoredThreadId + ? Deferred.succeed(restoredPreviewClosed, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + archiveThread: (threadId) => + threadId === blockingThreadId + ? Deferred.succeed(blockingArchiveStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBlockingArchive)), + ) + : Deferred.succeed(restoredArchiveStarted, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* PubSub.publish(events, archivedEvent(blockingThreadId)); + yield* Deferred.await(blockingArchiveStarted); + + yield* PubSub.publish(events, archivedEvent(restoredThreadId)); + yield* Deferred.await(restoredPreviewClosed); + yield* Ref.set(restored, true); + yield* Deferred.succeed(releaseBlockingArchive, undefined); + yield* Deferred.await(restoredArchiveStarted); + yield* reactor.drain; + + expect(yield* Ref.get(latePreviewCloseCalls)).toBe(0); + }).pipe(Effect.provide(layer)); + }), +); + effectIt.effect( "keeps archive cleanup fail-closed without a binding for an active projection", () => diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index f4f60e8045f..b27c9bd9edb 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -153,7 +153,6 @@ const make = Effect.gen(function* () { return; } const { threadId } = job; - yield* closeThreadPreviews(threadId); if (job.type === "archive") { // Archiving must not snapshot or delete hot rows while any active writer // can still mutate them. A failure leaves the durable archived shell or @@ -227,10 +226,18 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if (event.type === "thread.deleted") { - return enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }); + return closeThreadPreviews(event.payload.threadId).pipe( + Effect.andThen( + enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }), + ), + ); } if (event.type === "thread.archived") { - return enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }); + return closeThreadPreviews(event.payload.threadId).pipe( + Effect.andThen( + enqueueLifecycleJob({ type: "archive", threadId: event.payload.threadId }), + ), + ); } return Effect.void; }), diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 652508d5523..1b34d1f52f4 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -9,6 +9,7 @@ import { useTheme } from "~/hooks/useTheme"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { removePreviewThread, useActivePreviewSessions } from "~/previewStateStore"; import { useLiveEnvironmentIds, useThreadRefs } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; @@ -20,6 +21,8 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const activeThreadRefs = useThreadRefs(); const liveEnvironmentIds = useLiveEnvironmentIds(); + const { environmentIds: catalogEnvironmentIds, isReady: environmentCatalogReady } = + useEnvironments(); const previousActiveThreadRefs = useRef(activeThreadRefs); const sessions = useMemo( () => @@ -40,6 +43,7 @@ export function ElectronBrowserHost() { const reconciliation = reconcilePreviewThreadRefs({ previousActiveThreadRefs: previousActiveThreadRefs.current, activeThreadRefs, + catalogEnvironmentIds: environmentCatalogReady ? catalogEnvironmentIds : null, liveEnvironmentIds, }); previousActiveThreadRefs.current = reconciliation.nextActiveThreadRefs; @@ -47,7 +51,7 @@ export function ElectronBrowserHost() { removePreviewThread(threadRef); usePreviewMiniPlayerStore.getState().removeThread(threadRef); } - }, [activeThreadRefs, liveEnvironmentIds]); + }, [activeThreadRefs, catalogEnvironmentIds, environmentCatalogReady, liveEnvironmentIds]); useEffect(() => { const preview = window.desktopBridge?.preview; diff --git a/apps/web/src/browser/previewThreadLifecycle.test.ts b/apps/web/src/browser/previewThreadLifecycle.test.ts index 4c4d28c856c..9ba0792f2cc 100644 --- a/apps/web/src/browser/previewThreadLifecycle.test.ts +++ b/apps/web/src/browser/previewThreadLifecycle.test.ts @@ -16,6 +16,7 @@ describe("reconcilePreviewThreadRefs", () => { reconcilePreviewThreadRefs({ previousActiveThreadRefs: [activeRef, archivedRef, deletedRef], activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne, environmentTwo]), liveEnvironmentIds: new Set([environmentOne]), }), ).toEqual({ @@ -28,6 +29,7 @@ describe("reconcilePreviewThreadRefs", () => { const synchronizing = reconcilePreviewThreadRefs({ previousActiveThreadRefs: [activeRef, archivedRef], activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne]), liveEnvironmentIds: new Set(), }); expect(synchronizing).toEqual({ @@ -39,6 +41,7 @@ describe("reconcilePreviewThreadRefs", () => { reconcilePreviewThreadRefs({ previousActiveThreadRefs: synchronizing.nextActiveThreadRefs, activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne]), liveEnvironmentIds: new Set([environmentOne]), }), ).toEqual({ @@ -47,11 +50,40 @@ describe("reconcilePreviewThreadRefs", () => { }); }); + it("removes retained preview state when an environment leaves the catalog", () => { + expect( + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, deletedRef], + activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne]), + liveEnvironmentIds: new Set([environmentOne]), + }), + ).toEqual({ + removedThreadRefs: [deletedRef], + nextActiveThreadRefs: [activeRef], + }); + }); + + it("retains the prior baseline while the environment catalog is unavailable", () => { + expect( + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, deletedRef], + activeThreadRefs: [], + catalogEnvironmentIds: null, + liveEnvironmentIds: new Set(), + }), + ).toEqual({ + removedThreadRefs: [], + nextActiveThreadRefs: [activeRef, deletedRef], + }); + }); + it("does not report unchanged reordered thread references", () => { expect( reconcilePreviewThreadRefs({ previousActiveThreadRefs: [activeRef, archivedRef], activeThreadRefs: [{ ...archivedRef }, { ...activeRef }], + catalogEnvironmentIds: new Set([environmentOne]), liveEnvironmentIds: new Set([environmentOne]), }).removedThreadRefs, ).toEqual([]); diff --git a/apps/web/src/browser/previewThreadLifecycle.ts b/apps/web/src/browser/previewThreadLifecycle.ts index f3137b5f130..f0dd8a97746 100644 --- a/apps/web/src/browser/previewThreadLifecycle.ts +++ b/apps/web/src/browser/previewThreadLifecycle.ts @@ -4,6 +4,7 @@ import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; interface ReconcilePreviewThreadRefsInput { readonly previousActiveThreadRefs: readonly ScopedThreadRef[]; readonly activeThreadRefs: readonly ScopedThreadRef[]; + readonly catalogEnvironmentIds: ReadonlySet | null; readonly liveEnvironmentIds: ReadonlySet; } @@ -18,15 +19,22 @@ export function reconcilePreviewThreadRefs( const activeThreadKeys = new Set(input.activeThreadRefs.map(scopedThreadKey)); const removedThreadRefs = input.previousActiveThreadRefs.filter( (threadRef) => - input.liveEnvironmentIds.has(threadRef.environmentId) && - !activeThreadKeys.has(scopedThreadKey(threadRef)), + (input.catalogEnvironmentIds !== null && + !input.catalogEnvironmentIds.has(threadRef.environmentId)) || + (input.liveEnvironmentIds.has(threadRef.environmentId) && + !activeThreadKeys.has(scopedThreadKey(threadRef))), ); const nextActiveThreadRefs = [ ...input.previousActiveThreadRefs.filter( - (threadRef) => !input.liveEnvironmentIds.has(threadRef.environmentId), + (threadRef) => + (input.catalogEnvironmentIds === null || + input.catalogEnvironmentIds.has(threadRef.environmentId)) && + !input.liveEnvironmentIds.has(threadRef.environmentId), ), - ...input.activeThreadRefs.filter((threadRef) => - input.liveEnvironmentIds.has(threadRef.environmentId), + ...input.activeThreadRefs.filter( + (threadRef) => + input.catalogEnvironmentIds?.has(threadRef.environmentId) && + input.liveEnvironmentIds.has(threadRef.environmentId), ), ]; return { removedThreadRefs, nextActiveThreadRefs }; diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cd..10bb99d3d9e 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -39,6 +39,7 @@ export function useEnvironments() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + const environmentIds = useMemo(() => new Set(catalog.entries.keys()), [catalog.entries]); const environments = useMemo( () => @@ -51,6 +52,7 @@ export function useEnvironments() { return { isReady: catalog.isReady, networkStatus, + environmentIds, environments, presentationById, }; From cbea4d3e083b6e98ef866aff5cdba3f72f8d037d Mon Sep 17 00:00:00 2001 From: T3 Verification Date: Tue, 28 Jul 2026 15:49:33 +0100 Subject: [PATCH 58/63] Refactor archive and preview integration boundaries --- apps/web/src/browser/ElectronBrowserHost.tsx | 29 +- .../usePreviewThreadLifecycleCleanup.ts | 30 +++ .../settings/ArchivedThreadsPanel.tsx | 253 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 245 +---------------- apps/web/src/routes/settings.archived.tsx | 2 +- 5 files changed, 290 insertions(+), 269 deletions(-) create mode 100644 apps/web/src/browser/usePreviewThreadLifecycleCleanup.ts create mode 100644 apps/web/src/components/settings/ArchivedThreadsPanel.tsx diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index ecddada0934..b5b29769840 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -2,29 +2,22 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; -import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; -import { removePreviewThread, useActivePreviewSessions } from "~/previewStateStore"; -import { useLiveEnvironmentIds, useThreadRefs } from "~/state/entities"; -import { useEnvironments } from "~/state/environments"; +import { useActivePreviewSessions } from "~/previewStateStore"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; -import { reconcilePreviewThreadRefs } from "./previewThreadLifecycle"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; +import { usePreviewThreadLifecycleCleanup } from "./usePreviewThreadLifecycleCleanup"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); const previewByThreadKey = useActivePreviewSessions(); - const activeThreadRefs = useThreadRefs(); - const liveEnvironmentIds = useLiveEnvironmentIds(); - const { environmentIds: catalogEnvironmentIds, isReady: environmentCatalogReady } = - useEnvironments(); - const previousActiveThreadRefs = useRef(activeThreadRefs); + usePreviewThreadLifecycleCleanup(); const sessions = useMemo( () => Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { @@ -45,20 +38,6 @@ export function ElectronBrowserHost() { [previewByThreadKey], ); - useEffect(() => { - const reconciliation = reconcilePreviewThreadRefs({ - previousActiveThreadRefs: previousActiveThreadRefs.current, - activeThreadRefs, - catalogEnvironmentIds: environmentCatalogReady ? catalogEnvironmentIds : null, - liveEnvironmentIds, - }); - previousActiveThreadRefs.current = reconciliation.nextActiveThreadRefs; - for (const threadRef of reconciliation.removedThreadRefs) { - removePreviewThread(threadRef); - usePreviewMiniPlayerStore.getState().removeThread(threadRef); - } - }, [activeThreadRefs, catalogEnvironmentIds, environmentCatalogReady, liveEnvironmentIds]); - useEffect(() => { const preview = window.desktopBridge?.preview; if (!preview) return; diff --git a/apps/web/src/browser/usePreviewThreadLifecycleCleanup.ts b/apps/web/src/browser/usePreviewThreadLifecycleCleanup.ts new file mode 100644 index 00000000000..13e2c9544e3 --- /dev/null +++ b/apps/web/src/browser/usePreviewThreadLifecycleCleanup.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef } from "react"; + +import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { removePreviewThread } from "~/previewStateStore"; +import { useLiveEnvironmentIds, useThreadRefs } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; + +import { reconcilePreviewThreadRefs } from "./previewThreadLifecycle"; + +export function usePreviewThreadLifecycleCleanup(): void { + const activeThreadRefs = useThreadRefs(); + const liveEnvironmentIds = useLiveEnvironmentIds(); + const { environmentIds: catalogEnvironmentIds, isReady: environmentCatalogReady } = + useEnvironments(); + const previousActiveThreadRefs = useRef(activeThreadRefs); + + useEffect(() => { + const reconciliation = reconcilePreviewThreadRefs({ + previousActiveThreadRefs: previousActiveThreadRefs.current, + activeThreadRefs, + catalogEnvironmentIds: environmentCatalogReady ? catalogEnvironmentIds : null, + liveEnvironmentIds, + }); + previousActiveThreadRefs.current = reconciliation.nextActiveThreadRefs; + for (const threadRef of reconciliation.removedThreadRefs) { + removePreviewThread(threadRef); + usePreviewMiniPlayerStore.getState().removeThread(threadRef); + } + }, [activeThreadRefs, catalogEnvironmentIds, environmentCatalogReady, liveEnvironmentIds]); +} diff --git a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx new file mode 100644 index 00000000000..702e3975710 --- /dev/null +++ b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx @@ -0,0 +1,253 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { ArchiveIcon, ArchiveX, LoaderIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { useThreadActions } from "../../hooks/useThreadActions"; +import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; +import { readLocalApi } from "../../localApi"; +import { useProjects } from "../../state/entities"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ArchivedThreadsPanel() { + const projects = useProjects(); + const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const [unarchivingThreadKeys, setUnarchivingThreadKeys] = useState>( + () => new Set(), + ); + const environmentIds = useMemo( + () => [...new Set(projects.map((project) => project.environmentId))], + [projects], + ); + const { + snapshots: archivedSnapshots, + error: archiveError, + isLoading: isLoadingArchive, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(environmentIds); + + const archivedGroups = useMemo(() => { + const projectsByEnvironmentAndId = new Map( + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.projects.map( + (project) => + [ + `${environmentId}:${project.id}`, + { + id: project.id, + environmentId, + name: project.title, + cwd: project.workspaceRoot, + }, + ] as const, + ), + ), + ); + const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ + ...thread, + environmentId, + })), + ); + + const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); + const groups: Array<{ + readonly project: (typeof archivedProjects)[number]; + readonly threads: Array<(typeof threads)[number]>; + }> = []; + for (const project of archivedProjects) { + const projectThreads: Array<(typeof threads)[number]> = []; + for (const thread of threads) { + if (thread.projectId === project.id && thread.environmentId === project.environmentId) { + projectThreads.push(thread); + } + } + if (projectThreads.length > 0) { + groups.push({ + project, + threads: projectThreads.toSorted((left, right) => { + const leftKey = left.archivedAt ?? left.createdAt; + const rightKey = right.archivedAt ?? right.createdAt; + return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id); + }), + }); + } + } + return groups; + }, [archivedSnapshots]); + + const handleUnarchiveThread = useCallback( + async (threadRef: ScopedThreadRef) => { + const threadKey = scopedThreadKey(threadRef); + setUnarchivingThreadKeys((current) => new Set(current).add(threadKey)); + try { + const result = await unarchiveThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unarchive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnarchivingThreadKeys((current) => { + const next = new Set(current); + next.delete(threadKey); + return next; + }); + } + }, + [refreshArchivedThreads, unarchiveThread], + ); + + const handleArchivedThreadContextMenu = useCallback( + async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive", label: "Unarchive" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "unarchive") { + await handleUnarchiveThread(threadRef); + return; + } + + if (clicked === "delete") { + const result = await confirmAndDeleteThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } + }, + [confirmAndDeleteThread, handleUnarchiveThread, refreshArchivedThreads], + ); + + return ( + + {archivedGroups.length === 0 ? ( + + + {isLoadingArchive ? ( + + ) : ( + + )} + {isLoadingArchive + ? "Loading archived threads" + : archiveError + ? "Could not load archived threads" + : "No archived threads"} + + } + description={ + isLoadingArchive + ? "Checking connected environments." + : (archiveError ?? "Archived threads will appear here.") + } + /> + + ) : ( + archivedGroups.map(({ project, threads: projectThreads }) => ( + } + > + {projectThreads.map((thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const isUnarchiving = unarchivingThreadKeys.has(scopedThreadKey(threadRef)); + return ( + { + event.preventDefault(); + if (isUnarchiving) return; + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived thread action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }} + title={thread.title} + description={ + <> + Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} + {" \u00b7 Created "} + {formatRelativeTimeLabel(thread.createdAt)} + + } + control={ + + } + /> + ); + })} + + )) + )} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d35e3eb156c..d75ea3d5b38 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,4 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -10,14 +10,11 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, - type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, - settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { @@ -50,7 +47,6 @@ import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; -import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, @@ -68,9 +64,7 @@ import { serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; -import { useProjects } from "../../state/entities"; -import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; +import { getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -102,7 +96,6 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; -import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ @@ -1614,237 +1607,3 @@ export function ProviderSettingsPanel() { ); } - -export function ArchivedThreadsPanel() { - const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); - const [unarchivingThreadKeys, setUnarchivingThreadKeys] = useState>( - () => new Set(), - ); - const environmentIds = useMemo( - () => [...new Set(projects.map((project) => project.environmentId))], - [projects], - ); - const { - snapshots: archivedSnapshots, - error: archiveError, - isLoading: isLoadingArchive, - refresh: refreshArchivedThreads, - } = useArchivedThreadSnapshots(environmentIds); - - const archivedGroups = useMemo(() => { - const projectsByEnvironmentAndId = new Map( - archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.projects.map( - (project) => - [ - `${environmentId}:${project.id}`, - { - id: project.id, - environmentId, - name: project.title, - cwd: project.workspaceRoot, - }, - ] as const, - ), - ), - ); - const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.threads.map((thread) => ({ - ...thread, - environmentId, - })), - ); - - const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); - const groups: Array<{ - readonly project: (typeof archivedProjects)[number]; - readonly threads: Array<(typeof threads)[number]>; - }> = []; - for (const project of archivedProjects) { - const projectThreads: Array<(typeof threads)[number]> = []; - for (const thread of threads) { - if (thread.projectId === project.id && thread.environmentId === project.environmentId) { - projectThreads.push(thread); - } - } - if (projectThreads.length > 0) { - groups.push({ - project, - threads: projectThreads.toSorted((left, right) => { - const leftKey = left.archivedAt ?? left.createdAt; - const rightKey = right.archivedAt ?? right.createdAt; - return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id); - }), - }); - } - } - return groups; - }, [archivedSnapshots]); - - const handleUnarchiveThread = useCallback( - async (threadRef: ScopedThreadRef) => { - const threadKey = scopedThreadKey(threadRef); - setUnarchivingThreadKeys((current) => new Set(current).add(threadKey)); - try { - const result = await unarchiveThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } finally { - setUnarchivingThreadKeys((current) => { - const next = new Set(current); - next.delete(threadKey); - return next; - }); - } - }, - [refreshArchivedThreads, unarchiveThread], - ); - - const handleArchivedThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "unarchive") { - await handleUnarchiveThread(threadRef); - return; - } - - if (clicked === "delete") { - const result = await confirmAndDeleteThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } - }, - [confirmAndDeleteThread, handleUnarchiveThread, refreshArchivedThreads], - ); - - return ( - - {archivedGroups.length === 0 ? ( - - - {isLoadingArchive ? ( - - ) : ( - - )} - {isLoadingArchive - ? "Loading archived threads" - : archiveError - ? "Could not load archived threads" - : "No archived threads"} - - } - description={ - isLoadingArchive - ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") - } - /> - - ) : ( - archivedGroups.map(({ project, threads: projectThreads }) => ( - } - > - {projectThreads.map((thread) => { - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const isUnarchiving = unarchivingThreadKeys.has(scopedThreadKey(threadRef)); - return ( - { - event.preventDefault(); - if (isUnarchiving) return; - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived thread action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }} - title={thread.title} - description={ - <> - Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} - {" \u00b7 Created "} - {formatRelativeTimeLabel(thread.createdAt)} - - } - control={ - - } - /> - ); - })} - - )) - )} - - ); -} diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index 3ad690afc02..b411f6440af 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; +import { ArchivedThreadsPanel } from "../components/settings/ArchivedThreadsPanel"; export const Route = createFileRoute("/settings/archived")({ component: ArchivedThreadsPanel, From 02bfb7e5f4b104f9b10a66a18de06418d218ebf6 Mon Sep 17 00:00:00 2001 From: T3 Verification Date: Tue, 28 Jul 2026 15:52:22 +0100 Subject: [PATCH 59/63] Document archive integration boundaries --- BRANCH_DETAILS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 8ec8e319af3..79d219ca59e 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -24,6 +24,9 @@ Primary files: - `apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts` - `apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts` - `apps/server/scripts/t3-sqlite-state.ts` +- `apps/web/src/browser/previewThreadLifecycle.ts` +- `apps/web/src/browser/usePreviewThreadLifecycleCleanup.ts` +- `apps/web/src/components/settings/ArchivedThreadsPanel.tsx` - `apps/web/src/components/Sidebar.logic.ts` - `apps/web/src/components/Sidebar.logic.test.ts` - `apps/web/src/components/SidebarV2.tsx` @@ -32,13 +35,15 @@ Primary files: Sidebar archive visibility is centralized across persisted and optimistic archive states. Project rows, project status, sorting, keyboard navigation, prewarming, and command-palette thread/project actions exclude an optimistically archived conversation immediately, so durable cold-storage work cannot leave a stale row or navigation target visible while the server shell catches up. +The web Archive settings route renders `apps/web/src/components/settings/ArchivedThreadsPanel.tsx`, which reads archived snapshots across connected environments, groups archived threads by project, and exposes unarchive plus permanent-delete actions. Keeping this branch-owned surface separate from the general panels in `apps/web/src/components/settings/SettingsPanels.tsx` limits archive behavior to the Archive route. + Authoritative shell synchronization is shared runtime behavior in `packages/client-runtime/src/state/shell.ts`, `packages/client-runtime/src/rpc/client.ts`, and `apps/server/src/ws.ts`, rather than a branch-specific subscription implementation. HTTP snapshots provide an early shell, while completion-capable WebSocket sessions revalidate it with a socket-owned snapshot after live buffering begins; the same refresh runs when the app returns to the foreground. Cold archive storage relies on this contract because compacted per-thread history cannot prove that cached projects and threads still exist. Persisted web and mobile thread details are also fast-paint caches, not an archive source of truth. A `thread.archived` detail event, an authoritative archived detail snapshot, successful local archive acknowledgement, or shell removal evicts the persisted detail through `packages/client-runtime/src/state/threadCache.ts`. Per-thread cache generations, eviction tombstones, and write locks prevent queued or idle-finalizer writes from recreating the snapshot. Shell additions and `thread.unarchived` events revive the cache while invalidating only writes captured during the tombstoned generation. Web IndexedDB migration version 5 and mobile SQLite migration version 2 each clear legacy thread-detail caches once; active details repopulate on demand. Failed shell-driven detail-cache removals remain pending while the authoritative shell omits the thread and retry on later shell items. An authoritative active detail snapshot also revives a cache tombstone before it queues persistence, covering reconnects where the corresponding unarchive event was compacted. -`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` schedules lifecycle cleanup when it observes a thread archive or deletion event, including deletions expanded from a project command; it makes a best-effort attempt to close every server preview session at event observation time so delayed archive work cannot close a preview reopened after unarchive. `apps/web/src/browser/ElectronBrowserHost.tsx` uses `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live or discard the baseline when the environment leaves the catalog. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. +`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts` schedules lifecycle cleanup when it observes a thread archive or deletion event, including deletions expanded from a project command; it makes a best-effort attempt to close every server preview session at event observation time so delayed archive work cannot close a preview reopened after unarchive. `apps/web/src/browser/ElectronBrowserHost.tsx` calls `apps/web/src/browser/usePreviewThreadLifecycleCleanup.ts`, whose renderer lifecycle effect uses the pure reconciliation logic in `apps/web/src/browser/previewThreadLifecycle.ts` to retain the last authoritative per-environment thread baseline while a shell synchronizes, then detect removals once that environment is live or discard the baseline when the environment leaves the catalog. It clears both `apps/web/src/previewStateStore.ts` and `apps/web/src/previewMiniPlayerStore.ts` state even when server preview-close delivery arrives first. Removing the preview state unmounts the cross-thread background webview, whose `apps/web/src/browser/desktopTabLifetime.ts` lease stops recording/capture before it closes the desktop tab. Unarchive therefore starts with clean preview state instead of reviving a stale mini-player or capture session. `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts`, `apps/web/src/browser/previewThreadLifecycle.test.ts`, and `apps/web/src/previewMiniPlayerStore.test.ts` cover the server and renderer cleanup boundaries. `apps/server/src/server.test.ts` verifies that HTTP-seeded shell subscriptions preserve archive removals published while WebSocket catch-up reads persisted events, then emit the synchronization completion marker. Initial-snapshot event replay in `apps/server/src/ws.ts` and deferred active-thread cache writes in `packages/client-runtime/src/state/threads.ts` remain complementary to authoritative shell refresh. Cache lifecycle behavior is covered through `packages/client-runtime/src/state/shell-sync.test.ts`, `packages/client-runtime/src/state/threadCommands.test.ts`, `packages/client-runtime/src/state/threads-sync.test.ts`, `apps/web/src/connection/storage.test.ts`, and the mobile storage/database tests. From f9f882e781d975d4ad993e0860350bc4fc6be47c Mon Sep 17 00:00:00 2001 From: T3 Verification Date: Tue, 28 Jul 2026 17:43:36 +0100 Subject: [PATCH 60/63] Preserve lifecycle state across archive retries - Keep command receipts hot and rediscover missed deletions - Avoid stale restore replays and retain live preview baselines --- BRANCH_DETAILS.md | 6 +- .../Layers/ThreadColdStorage.test.ts | 118 ++++++++++++++++++ .../orchestration/Layers/ThreadColdStorage.ts | 26 +++- .../browser/previewThreadLifecycle.test.ts | 14 +++ .../web/src/browser/previewThreadLifecycle.ts | 3 +- 5 files changed, 158 insertions(+), 9 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 79d219ca59e..cef3728923b 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -2,14 +2,14 @@ Archived conversations use cold storage instead of retaining full hot projections and diagnostics in `state.sqlite`. -- `state.sqlite` keeps only the lightweight archived thread shell needed by the Archive page. Conversation events, messages, activities, turns, checkpoints, plans, session/runtime rows, and content attachments are written as bounded gzip-compressed chunks in the separate `archive.sqlite` database, then removed from hot storage. +- `state.sqlite` keeps only the lightweight archived thread shell needed by the Archive page plus command receipts required for retry idempotency. Conversation events, messages, activities, turns, checkpoints, plans, session/runtime rows, and content attachments are written as bounded gzip-compressed chunks in the separate `archive.sqlite` database, then removed from hot storage. Command receipts remain hot until permanent deletion so retrying an accepted archive command returns its original sequence. - `apps/server/scripts/t3-sqlite-state.ts` is the supported isolated-database inspection path. It targets hot `state.sqlite` by default and accepts `--database archive` for cold manifest and chunk queries, with both paths derived from the shared server configuration. Archive and restore behavior should use application commands rather than direct archive-bundle writes. - Content attachments are part of the archive bundle and return on unarchive. Attachment collection follows exact ids persisted in thread messages, while retry cleanup may reuse exact attachment chunk filenames already recorded in the cold bundle; normalized thread-name collisions cannot claim another thread's files. Provider diagnostic logs and terminal history logs are deliberately destructive on archive: they are deleted, never copied into the bundle, and never restored. Provider-log cleanup matches only the exact thread log and its numeric rotations so similarly prefixed thread ids are not affected. - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. -- Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. +- Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. Startup recovery also rediscovers soft-deleted shells that have not reached the cleanup queue, covering commits made while the lifecycle reactor was offline. - Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus the per-member thread partitioning that selects each member's command options, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. -- Unarchive restores `cold`, `restored`, or `cleanup_pending` bundles before dispatching the domain command. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. Retrying an accepted unarchive receipt also retries idempotent bundle finalization for that same thread, so a transient post-commit cleanup failure cannot strand restored archive chunks. +- Unarchive restores `cold` or `cleanup_pending` bundles before dispatching the domain command and treats an existing `restored` manifest as an in-flight or post-commit reservation without replaying its stale chunks over live rows or attachments. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. Retrying an accepted unarchive receipt also retries idempotent bundle finalization for that same thread, so a transient post-commit cleanup failure cannot strand restored archive chunks. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. - A `restored` manifest protects only the archive epoch that an unarchive command reserved. If post-commit bundle finalization fails and the user later archives the thread again, the newer shell timestamp lets lifecycle work replace the stale manifest and move the conversation cold. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 25dfd2277f0..df4f8a7bab2 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -53,6 +53,23 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("discovers deleted shells before a cleanup queue entry exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-delete-queue-fallback"); + + yield* insertArchivedThread(threadId, "Delete queue fallback thread"); + yield* sql` + UPDATE projection_threads + SET deleted_at = '2026-07-03T00:00:00.000Z' + WHERE thread_id = ${threadId} + `; + + assert.deepInclude(yield* storage.listPendingDeleteThreadIds, threadId); + }), + ); + it.effect("reserves hot archived rows while an unarchive command is pending", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -146,6 +163,107 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("does not replay a restored bundle over active thread data", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadId = ThreadId.make("thread-stale-restored-bundle"); + const attachmentName = + "thread-stale-restored-bundle-00000000-0000-4000-8000-000000000001.txt"; + const attachmentPath = path.join(config.attachmentsDir, attachmentName); + + yield* insertArchivedThread(threadId, "Stale restored bundle"); + yield* sql.unsafe( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES (?, ?, NULL, 'user', 'cold value', ?, 0, ?, ?)`, + [ + "message-stale-restored-bundle", + threadId, + encodeUnknownJsonString([ + { + type: "text", + id: attachmentName.slice(0, -4), + name: "content.txt", + mimeType: "text/plain", + }, + ]), + "2026-07-01T00:00:00.000Z", + "2026-07-01T00:00:00.000Z", + ], + ); + yield* fs.writeFileString(attachmentPath, "cold attachment"); + + yield* storage.archiveThread(threadId); + assert.isTrue(yield* storage.restoreTree(threadId)); + + yield* sql` + UPDATE projection_threads SET archived_at = NULL WHERE thread_id = ${threadId} + `; + yield* sql` + UPDATE projection_thread_messages + SET text = 'active value' + WHERE thread_id = ${threadId} + `; + yield* fs.writeFileString(attachmentPath, "active attachment"); + + assert.isTrue(yield* storage.restoreTree(threadId)); + + const messages = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(messages, [{ text: "active value" }]); + assert.strictEqual(yield* fs.readFileString(attachmentPath), "active attachment"); + }), + ); + + it.effect("keeps command receipts hot while conversation data is cold", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-hot-command-receipt"); + + yield* insertArchivedThread(threadId, "Hot command receipt"); + yield* sql` + INSERT INTO orchestration_command_receipts ( + command_id, aggregate_kind, aggregate_id, accepted_at, + result_sequence, status, error + ) VALUES ( + 'command-hot-receipt', 'thread', ${threadId}, + '2026-07-02T00:00:00.000Z', 42, 'accepted', NULL + ) + `; + + yield* storage.archiveThread(threadId); + + const hotReceipts = yield* sql<{ readonly commandId: string }>` + SELECT command_id AS "commandId" + FROM orchestration_command_receipts + WHERE aggregate_id = ${threadId} + `; + const receiptChunks = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM cold_archive.archive_thread_chunks + WHERE thread_id = ${threadId} + AND kind = 'table:orchestration_command_receipts' + `; + assert.deepStrictEqual(hotReceipts, [{ commandId: "command-hot-receipt" }]); + assert.deepStrictEqual(receiptChunks, [{ count: 0 }]); + + yield* storage.deleteThread(threadId); + const deletedReceipts = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM orchestration_command_receipts + WHERE aggregate_id = ${threadId} + `; + assert.deepStrictEqual(deletedReceipts, [{ count: 0 }]); + }), + ); + it.effect("compresses conversation data, destroys logs, restores content, and hard-deletes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index b6b519dafe7..91674f000b4 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -45,9 +45,8 @@ class ArchiveCodecError extends Data.TaggedError("ArchiveCodecError")<{ readonly cause: unknown; }> {} -const THREAD_TABLES = [ +const ARCHIVED_THREAD_TABLES = [ ["orchestration_events", "stream_id"], - ["orchestration_command_receipts", "aggregate_id"], ["checkpoint_diff_blobs", "thread_id"], ["provider_session_runtime", "thread_id"], ["projection_thread_messages", "thread_id"], @@ -58,6 +57,11 @@ const THREAD_TABLES = [ ["projection_thread_proposed_plans", "thread_id"], ] as const; +const THREAD_TABLES = [ + ...ARCHIVED_THREAD_TABLES, + ["orchestration_command_receipts", "aggregate_id"] as const, +] as const; + function storageError(operation: string, threadId: string, cause: unknown) { return new ThreadColdStorageError({ operation, threadId, cause }); } @@ -398,7 +402,7 @@ const make = Effect.gen(function* () { let chunkIndex = 0; let originalBytes = 0; let compressedBytes = 0; - for (const [table, keyColumn] of THREAD_TABLES) { + for (const [table, keyColumn] of ARCHIVED_THREAD_TABLES) { let lastRowId = 0; while (true) { const rows = (yield* sql.unsafe( @@ -479,7 +483,7 @@ const make = Effect.gen(function* () { VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, [threadId, rootThreadId, ARCHIVE_VERSION, archivedAt, originalBytes, compressedBytes], ); - for (const [table, keyColumn] of [...THREAD_TABLES].toReversed()) { + for (const [table, keyColumn] of [...ARCHIVED_THREAD_TABLES].toReversed()) { yield* sql.unsafe(`DELETE FROM ${table} WHERE ${keyColumn} = ?`, [threadId]); } yield* sql.unsafe( @@ -634,6 +638,7 @@ const make = Effect.gen(function* () { )) as ReadonlyArray; let restored = false; for (const row of rows) { + if (row.status === "restored") continue; restored = (yield* restoreThread(ThreadId.make(String(row.thread_id)))) || restored; } if (restored || rows.some((row) => row.status === "restored")) { @@ -868,7 +873,18 @@ const make = Effect.gen(function* () { "list-pending-archives", ), listPendingDeleteThreadIds: listIds( - `SELECT thread_id FROM thread_cleanup_queue WHERE reason = 'deleted' ORDER BY created_at ASC, thread_id ASC`, + `SELECT thread_id + FROM ( + SELECT thread_id, created_at + FROM thread_cleanup_queue + WHERE reason = 'deleted' + UNION ALL + SELECT thread_id, deleted_at AS created_at + FROM projection_threads + WHERE deleted_at IS NOT NULL + ) + GROUP BY thread_id + ORDER BY MIN(created_at) ASC, thread_id ASC`, "list-pending-deletes", ), } satisfies ThreadColdStorage["Service"]; diff --git a/apps/web/src/browser/previewThreadLifecycle.test.ts b/apps/web/src/browser/previewThreadLifecycle.test.ts index 9ba0792f2cc..ecce32c4058 100644 --- a/apps/web/src/browser/previewThreadLifecycle.test.ts +++ b/apps/web/src/browser/previewThreadLifecycle.test.ts @@ -78,6 +78,20 @@ describe("reconcilePreviewThreadRefs", () => { }); }); + it("refreshes live baselines while the environment catalog is unavailable", () => { + expect( + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [archivedRef], + activeThreadRefs: [activeRef], + catalogEnvironmentIds: null, + liveEnvironmentIds: new Set([environmentOne]), + }), + ).toEqual({ + removedThreadRefs: [archivedRef], + nextActiveThreadRefs: [activeRef], + }); + }); + it("does not report unchanged reordered thread references", () => { expect( reconcilePreviewThreadRefs({ diff --git a/apps/web/src/browser/previewThreadLifecycle.ts b/apps/web/src/browser/previewThreadLifecycle.ts index f0dd8a97746..f3efa921240 100644 --- a/apps/web/src/browser/previewThreadLifecycle.ts +++ b/apps/web/src/browser/previewThreadLifecycle.ts @@ -33,7 +33,8 @@ export function reconcilePreviewThreadRefs( ), ...input.activeThreadRefs.filter( (threadRef) => - input.catalogEnvironmentIds?.has(threadRef.environmentId) && + (input.catalogEnvironmentIds === null || + input.catalogEnvironmentIds.has(threadRef.environmentId)) && input.liveEnvironmentIds.has(threadRef.environmentId), ), ]; From db348f1adedc5c37010e00e786ab02d074912072 Mon Sep 17 00:00:00 2001 From: T3 Verification Date: Wed, 29 Jul 2026 00:35:54 +0100 Subject: [PATCH 61/63] test(client-runtime): update archive cache fixtures --- packages/client-runtime/src/state/shell-sync.test.ts | 2 ++ packages/client-runtime/src/state/threadCommands.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 8188e8f86c2..a148f1fcfb2 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -342,6 +342,8 @@ describe("environment shell synchronization", () => { saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); const shellState = yield* makeEnvironmentShellState().pipe( diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 59cadd53061..3a78a7d277b 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -77,6 +77,8 @@ function makeCache( saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); } From 61565ad58d9ab4c140d01e8c278f84a1ada4ac9f Mon Sep 17 00:00:00 2001 From: T3 Verification Date: Wed, 29 Jul 2026 11:50:09 +0100 Subject: [PATCH 62/63] Recover orphaned restore reservations - Track live restore ownership to protect active unarchives - Requeue abandoned restored manifests for cold storage - Cover restart recovery and document lifecycle behavior --- BRANCH_DETAILS.md | 2 +- .../Layers/ThreadColdStorage.test.ts | 46 ++++++++ .../orchestration/Layers/ThreadColdStorage.ts | 109 ++++++++++++------ 3 files changed, 121 insertions(+), 36 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index cef3728923b..967318f6a87 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -10,7 +10,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus the per-member thread partitioning that selects each member's command options, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. - Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold` or `cleanup_pending` bundles before dispatching the domain command and treats an existing `restored` manifest as an in-flight or post-commit reservation without replaying its stale chunks over live rows or attachments. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. Retrying an accepted unarchive receipt also retries idempotent bundle finalization for that same thread, so a transient post-commit cleanup failure cannot strand restored archive chunks. -- Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. +- Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. Process-local restore ownership distinguishes those live reservations from `restored` manifests abandoned by a crashed process, which startup lifecycle discovery moves cold again while the shell remains archived. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. - A `restored` manifest protects only the archive epoch that an unarchive command reserved. If post-commit bundle finalization fails and the user later archives the thread again, the newer shell timestamp lets lifecycle work replace the stale manifest and move the conversation cold. - Migration `035_ThreadColdArchive` registers existing archived conversations for background conversion on the next update. Migration `036_DeletedThreadCleanupQueue` registers legacy soft-deleted conversations for the same permanent cleanup used by new deletes. - After the legacy queues drain, a retryable one-time `VACUUM` physically compacts `state.sqlite` and enables incremental auto-vacuum. Later lifecycle operations reclaim bounded free-page batches from both `state.sqlite` and `archive.sqlite`, avoiding a full compaction on every archive. diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index df4f8a7bab2..4d0c62ce89b 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -89,6 +89,7 @@ layer("ThreadColdStorage", (it) => { `; assert.isTrue(yield* storage.restoreTree(threadId)); + assert.deepInclude(yield* storage.listPendingArchiveThreadIds, threadId); yield* storage.archiveThread(threadId); const messages = yield* sql<{ readonly text: string }>` @@ -108,6 +109,51 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("re-archives an orphaned restore reservation discovered after restart", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-orphaned-restore-reservation"); + + yield* insertArchivedThread(threadId, "Orphaned restore reservation"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-orphaned-restore-reservation', ${threadId}, NULL, 'user', + 'move cold during startup recovery', '[]', 0, + '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO thread_archive_manifests ( + thread_id, root_thread_id, status, archive_version, + archived_at, updated_at, error + ) VALUES ( + ${threadId}, ${threadId}, 'restored', 1, + '2026-07-02T00:00:00.000Z', CURRENT_TIMESTAMP, NULL + ) + `; + + assert.deepInclude(yield* storage.listPendingArchiveThreadIds, threadId); + yield* storage.archiveThread(threadId); + + const messages = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_thread_messages + WHERE thread_id = ${threadId} + `; + const manifest = yield* sql<{ readonly status: string }>` + SELECT status + FROM thread_archive_manifests + WHERE thread_id = ${threadId} + `; + assert.deepStrictEqual(messages, [{ count: 0 }]); + assert.deepStrictEqual(manifest, [{ status: "cold" }]); + }), + ); + it.effect("re-archives hot rows after restored-bundle finalization fails", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index 91674f000b4..d145d027bf2 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -164,6 +164,7 @@ const make = Effect.gen(function* () { const path = yield* Path.Path; const config = yield* ServerConfig; const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const activeRestoreRoots = new Set(); yield* sql.unsafe(`ATTACH DATABASE ? AS ${ARCHIVE_SCHEMA}`, [config.archiveDbPath]); yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.auto_vacuum = INCREMENTAL`); @@ -364,17 +365,20 @@ const make = Effect.gen(function* () { } return; } + const rootThreadId = String(source.root_thread_id ?? threadId); // A restored manifest reserves this archive epoch for an in-flight - // unarchive. A later archive has a new shell timestamp and may replace a - // reservation that finishRestoreTree failed to remove after commit. + // unarchive only while this process owns that restore. A reservation left + // behind by a crashed process is durable recovery work, while a later + // archive has a new shell timestamp and may replace a reservation that + // finishRestoreTree failed to remove after commit. if ( source.status === "restored" && !allowRestored && + activeRestoreRoots.has(rootThreadId) && String(source.archived_at) === String(threadRows[0]?.archived_at) ) { return; } - const rootThreadId = String(source.root_thread_id ?? threadId); const archivedAt = String( threadRows[0]?.archived_at ?? source.archived_at ?? DateTime.formatIso(yield* DateTime.now), ); @@ -642,6 +646,7 @@ const make = Effect.gen(function* () { restored = (yield* restoreThread(ThreadId.make(String(row.thread_id)))) || restored; } if (restored || rows.some((row) => row.status === "restored")) { + activeRestoreRoots.add(String(rootThreadId)); return true; } if (rows.length > 0) { @@ -653,7 +658,7 @@ const make = Effect.gen(function* () { // tree lock, so a queued archive job cannot delete them before that command // commits. A failed command rolls this reservation back through archiveImpl; // a successful command removes it through finishRestoreTreeImpl. - return yield* sql.withTransaction( + const reserved = yield* sql.withTransaction( Effect.gen(function* () { const archivedShell = (yield* sql.unsafe( `SELECT archived_at @@ -679,50 +684,76 @@ const make = Effect.gen(function* () { return true; }), ); + if (reserved) { + activeRestoreRoots.add(String(rootThreadId)); + } + return reserved; }); const rollbackRestoreTreeImpl = Effect.fn("rollbackRestoreArchiveTreeImpl")(function* ( threadId: ThreadId, ) { const rootThreadId = yield* resolveTreeRoot(threadId); - const rows = (yield* sql.unsafe( - `SELECT thread_id - FROM thread_archive_manifests - WHERE root_thread_id = ? AND status = 'restored' - ORDER BY CASE WHEN thread_id = ? THEN 1 ELSE 0 END, thread_id ASC`, - [rootThreadId, rootThreadId], - )) as ReadonlyArray; - for (const row of rows) { - yield* archiveImpl(ThreadId.make(String(row.thread_id)), true); - } + yield* Effect.gen(function* () { + const rows = (yield* sql.unsafe( + `SELECT thread_id + FROM thread_archive_manifests + WHERE root_thread_id = ? AND status = 'restored' + ORDER BY CASE WHEN thread_id = ? THEN 1 ELSE 0 END, thread_id ASC`, + [rootThreadId, rootThreadId], + )) as ReadonlyArray; + yield* Effect.forEach( + rows, + (row) => archiveImpl(ThreadId.make(String(row.thread_id)), true), + { + discard: true, + }, + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + activeRestoreRoots.delete(String(rootThreadId)); + }), + ), + ); }); const finishRestoreTreeImpl = Effect.fn("finishRestoreArchiveTreeImpl")(function* ( threadId: ThreadId, ) { const rootThreadId = yield* resolveTreeRoot(threadId); - const rows = (yield* sql.unsafe( - `SELECT thread_id FROM thread_archive_manifests WHERE root_thread_id = ? AND status = 'restored'`, - [rootThreadId], - )) as ReadonlyArray; - yield* sql.withTransaction( - Effect.gen(function* () { - for (const row of rows) { - const restoredThreadId = String(row.thread_id); - yield* sql.unsafe( - `DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, - [restoredThreadId], - ); - yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ - restoredThreadId, - ]); - yield* sql.unsafe(`DELETE FROM thread_archive_manifests WHERE thread_id = ?`, [ - restoredThreadId, - ]); - } - }), + yield* Effect.gen(function* () { + const rows = (yield* sql.unsafe( + `SELECT thread_id + FROM thread_archive_manifests + WHERE root_thread_id = ? AND status = 'restored'`, + [rootThreadId], + )) as ReadonlyArray; + yield* sql.withTransaction( + Effect.gen(function* () { + for (const row of rows) { + const restoredThreadId = String(row.thread_id); + yield* sql.unsafe( + `DELETE FROM ${ARCHIVE_SCHEMA}.archive_thread_chunks WHERE thread_id = ?`, + [restoredThreadId], + ); + yield* sql.unsafe(`DELETE FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ?`, [ + restoredThreadId, + ]); + yield* sql.unsafe(`DELETE FROM thread_archive_manifests WHERE thread_id = ?`, [ + restoredThreadId, + ]); + } + }), + ); + yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.incremental_vacuum(2048)`); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + activeRestoreRoots.delete(String(rootThreadId)); + }), + ), ); - yield* sql.unsafe(`PRAGMA ${ARCHIVE_SCHEMA}.incremental_vacuum(2048)`); }); const deleteImpl = Effect.fn("deleteThreadPermanentlyImpl")(function* (threadId: ThreadId) { @@ -860,6 +891,14 @@ const make = Effect.gen(function* () { FROM thread_archive_manifests WHERE status IN ('pending', 'archiving', 'cleanup_pending') UNION ALL + SELECT thread_archive_manifests.thread_id, thread_archive_manifests.archived_at + FROM thread_archive_manifests + INNER JOIN projection_threads + ON projection_threads.thread_id = thread_archive_manifests.thread_id + AND projection_threads.deleted_at IS NULL + AND projection_threads.archived_at = thread_archive_manifests.archived_at + WHERE thread_archive_manifests.status = 'restored' + UNION ALL SELECT projection_threads.thread_id, projection_threads.archived_at FROM projection_threads WHERE projection_threads.archived_at IS NOT NULL From 20a7a77acbcb349ed0bce6e0e4c96dd12961ee74 Mon Sep 17 00:00:00 2001 From: T3 Verification Date: Wed, 29 Jul 2026 22:24:16 +0100 Subject: [PATCH 63/63] Serialize archive quiescing with restore - Run archive cleanup under the tree lock - Structure archive validation failures --- BRANCH_DETAILS.md | 2 +- .../Layers/ThreadColdStorage.test.ts | 42 +++++++++- .../orchestration/Layers/ThreadColdStorage.ts | 82 +++++++++++++------ .../Layers/ThreadDeletionReactor.test.ts | 56 ++++++++++++- .../Layers/ThreadDeletionReactor.ts | 17 ++-- .../Services/ThreadColdStorage.ts | 5 +- 6 files changed, 168 insertions(+), 36 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 967318f6a87..3511828e038 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -8,7 +8,7 @@ Archived conversations use cold storage instead of retaining full hot projection - Cold restore preserves binary SQL values, validates attachment entry names, and atomically replaces attachment files before marking SQL rows restored. It keeps compressed chunks authoritative on failure, pages chunk reads to bound memory, rejects unknown tables/chunk kinds, and intersects archived row columns with the current schema so older bundles remain recoverable after compatible migrations. - Permanent thread deletion removes the shell, event stream, command receipts, every thread-owned projection/runtime/checkpoint row, attachments, terminal history, provider logs and rotations, and any cold-archive manifest/chunks. Exact attachment ownership metadata remains available until external attachment and provider-log cleanup succeeds, after which the SQL and cold-chunk rows are removed; cross-thread plan references are cleared rather than leaving dangling ids, and the durable cleanup-queue entry is retained until filesystem cleanup and free-page reclamation succeed so interrupted deletes retry safely. Startup recovery also rediscovers soft-deleted shells that have not reached the cleanup queue, covering commits made while the lifecycle reactor was offline. - Sidebar V2 project removal in `apps/web/src/components/SidebarV2.tsx` applies the archived-history warning and command options from `apps/web/src/components/Sidebar.logic.ts` independently to each selected project member. Members with known live threads use forced deletion; when a member has no live shell threads, it requests archived-thread deletion without force because archived shells are excluded from normal navigation state. The server removes archived cold bundles but still rejects any unseen live thread. Project removal emits the same per-thread deletion events for archived shells, so already-cold threads pass through the durable lifecycle worker and lose their hot shell, archive manifest, and compressed chunks before cleanup completes. `apps/web/src/components/Sidebar.logic.test.ts` covers standalone and grouped warning text plus the per-member thread partitioning that selects each member's command options, `apps/server/src/orchestration/decider.delete.test.ts` covers the archived-only live-thread precondition, and `apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts` covers the forced-project decider, lifecycle reactor, and cold-storage boundary together. -- Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. +- Archive/delete filesystem work runs through a durable background lifecycle queue so command acknowledgement and the UI are not blocked by compression or large cleanup operations. Archive and restore lifecycle work is serialized per project tree, with reference-counted idle-lock eviction after the final user or waiter. Archive eligibility and provider/terminal/log-writer shutdown run under that same tree lock, so a queued archive job that becomes stale after unarchive cannot stop the newly active session or delete its terminal history. Archive creation uses a retry-safe two-phase boundary: compressed chunks and hot-row deletion commit before the manifest enters `cleanup_pending`, and destructive attachment/log cleanup must finish before it becomes `cold`. Destructive archive transitions recheck the archived shell inside the transaction and require terminal/log-writer shutdown to succeed. Provider shutdown also remains required while the projected session is `starting` or `running`; a missing provider binding is terminal for a settled or absent projected session, allowing legacy subagent shells that never owned a provider binding to move cold safely. Failed lifecycle work requests one coalesced delayed rescan of the durable pending state instead of being dropped or introducing a permanent polling loop, enqueue interruptions release their deduplication reservation, and restart recovery preserves the same retry boundary. Filesystem failures other than a genuinely missing directory also keep retry state. Incomplete cleanup, including a `cleanup_pending` manifest whose archived shell has already been removed, and archived shells missing a manifest resume after restart without rebuilding an already durable bundle. - Unarchive restores `cold` or `cleanup_pending` bundles before dispatching the domain command and treats an existing `restored` manifest as an in-flight or post-commit reservation without replaying its stale chunks over live rows or attachments. A rejected or failed command re-archives the restored rows and files, while a successful command finalizes the cold bundle only when that request actually performed a restore; this prevents unrelated or already-hot unarchive commands from deleting archive data. Once SQL restoration commits, later publication or metrics failures cannot re-archive the restored data. Retrying an accepted unarchive receipt also retries idempotent bundle finalization for that same thread, so a transient post-commit cleanup failure cannot strand restored archive chunks. - Unarchive reserves still-hot archived rows with a `restored` manifest before releasing the archive-tree lock. This prevents queued lifecycle work from moving those rows cold between the restore check and the unarchive command commit; command failure archives the reserved rows, while success removes the reservation. Process-local restore ownership distinguishes those live reservations from `restored` manifests abandoned by a crashed process, which startup lifecycle discovery moves cold again while the shell remains archived. If storage cannot restore or reserve the archived conversation, the command is rejected before an active-shell event can commit. - A `restored` manifest protects only the archive epoch that an unarchive command reserved. If post-commit bundle finalization fails and the user later archives the thread again, the newer shell timestamp lets lifecycle work replace the stale manifest and move the conversation cold. diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts index 4d0c62ce89b..342f0c0e2e5 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -90,7 +90,13 @@ layer("ThreadColdStorage", (it) => { assert.isTrue(yield* storage.restoreTree(threadId)); assert.deepInclude(yield* storage.listPendingArchiveThreadIds, threadId); - yield* storage.archiveThread(threadId); + let quiesceCalls = 0; + yield* storage.archiveThread( + threadId, + Effect.sync(() => { + quiesceCalls += 1; + }), + ); const messages = yield* sql<{ readonly text: string }>` SELECT text FROM projection_thread_messages WHERE thread_id = ${threadId} @@ -98,6 +104,7 @@ layer("ThreadColdStorage", (it) => { const manifest = yield* sql<{ readonly status: string }>` SELECT status FROM thread_archive_manifests WHERE thread_id = ${threadId} `; + assert.strictEqual(quiesceCalls, 0); assert.deepStrictEqual(messages, [{ text: "keep hot until unarchive commits" }]); assert.deepStrictEqual(manifest, [{ status: "restored" }]); @@ -109,6 +116,39 @@ layer("ThreadColdStorage", (it) => { }), ); + it.effect("reports unknown archive chunk kinds with structured validation detail", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const storage = yield* ThreadColdStorage; + const threadId = ThreadId.make("thread-unknown-archive-chunk-kind"); + + yield* insertArchivedThread(threadId, "Unknown archive chunk kind"); + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES ( + 'message-unknown-archive-chunk-kind', ${threadId}, NULL, 'user', + 'corrupt this chunk kind', '[]', 0, + '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z' + ) + `; + + yield* storage.archiveThread(threadId); + yield* sql` + UPDATE cold_archive.archive_thread_chunks + SET kind = 'future:unsupported' + WHERE thread_id = ${threadId} + `; + + const failure = yield* Effect.flip(storage.restoreTree(threadId)); + assert.deepInclude(failure.cause, { + _tag: "ArchiveChunkKindValidationError", + chunkKind: "future:unsupported", + }); + }), + ); + it.effect("re-archives an orphaned restore reservation discovered after restart", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts index d145d027bf2..61e3d13b2c6 100644 --- a/apps/server/src/orchestration/Layers/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -3,7 +3,6 @@ import * as NodeUtil from "node:util"; import * as NodeZlib from "node:zlib"; import { ThreadId } from "@t3tools/contracts"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -41,9 +40,36 @@ type AcquiredThreadLock = { readonly semaphore: Semaphore.Semaphore; }; -class ArchiveCodecError extends Data.TaggedError("ArchiveCodecError")<{ - readonly cause: unknown; -}> {} +class ArchiveCodecError extends Schema.TaggedErrorClass()("ArchiveCodecError", { + operation: Schema.Literals(["encode", "decode", "compress", "decompress"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Archive ${this.operation} failed`; + } +} + +class ArchiveTableValidationError extends Schema.TaggedErrorClass()( + "ArchiveTableValidationError", + { + table: Schema.String, + }, +) { + override get message(): string { + return `Archive chunk targets unknown table '${this.table}'`; + } +} + +class ArchiveChunkKindValidationError extends Schema.TaggedErrorClass()( + "ArchiveChunkKindValidationError", + { + chunkKind: Schema.String, + }, +) { + override get message(): string { + return `Archive contains unknown chunk kind '${this.chunkKind}'`; + } +} const ARCHIVED_THREAD_TABLES = [ ["orchestration_events", "stream_id"], @@ -62,10 +88,6 @@ const THREAD_TABLES = [ ["orchestration_command_receipts", "aggregate_id"] as const, ] as const; -function storageError(operation: string, threadId: string, cause: unknown) { - return new ThreadColdStorageError({ operation, threadId, cause }); -} - function encodeRows(rows: ReadonlyArray): Effect.Effect { return encodeUnknownJsonString( rows.map((row) => @@ -80,7 +102,7 @@ function encodeRows(rows: ReadonlyArray): Effect.Effect Buffer.from(encoded, "utf8")), - Effect.mapError((cause) => new ArchiveCodecError({ cause })), + Effect.mapError((cause) => new ArchiveCodecError({ operation: "encode", cause })), ); } @@ -120,11 +142,13 @@ function decodeRows(data: Uint8Array): Effect.Effect, Arch ); }); }, - catch: (cause) => new ArchiveCodecError({ cause }), + catch: (cause) => new ArchiveCodecError({ operation: "decode", cause }), }), ), Effect.mapError((cause) => - cause instanceof ArchiveCodecError ? cause : new ArchiveCodecError({ cause }), + cause instanceof ArchiveCodecError + ? cause + : new ArchiveCodecError({ operation: "decode", cause }), ), ); } @@ -149,13 +173,13 @@ function isSafeAttachmentEntry(entry: string): boolean { const compress = (data: Uint8Array) => Effect.tryPromise({ try: () => gzipAsync(data), - catch: (cause) => new ArchiveCodecError({ cause }), + catch: (cause) => new ArchiveCodecError({ operation: "compress", cause }), }).pipe(Effect.map((value) => new Uint8Array(value))); const decompress = (data: Uint8Array) => Effect.tryPromise({ try: () => gunzipAsync(data), - catch: (cause) => new ArchiveCodecError({ cause }), + catch: (cause) => new ArchiveCodecError({ operation: "decompress", cause }), }).pipe(Effect.map((value) => new Uint8Array(value))); const make = Effect.gen(function* () { @@ -338,6 +362,7 @@ const make = Effect.gen(function* () { const archiveImpl = Effect.fn("archiveThreadImpl")(function* ( threadId: ThreadId, allowRestored: boolean, + quiesce: Effect.Effect, ) { const manifestRows = (yield* sql.unsafe( `SELECT root_thread_id, archived_at, status @@ -379,6 +404,7 @@ const make = Effect.gen(function* () { ) { return; } + yield* quiesce; const archivedAt = String( threadRows[0]?.archived_at ?? source.archived_at ?? DateTime.formatIso(yield* DateTime.now), ); @@ -511,9 +537,7 @@ const make = Effect.gen(function* () { rows: ReadonlyArray, ) { if (!THREAD_TABLE_NAMES.has(table)) { - return yield* new ArchiveCodecError({ - cause: new TypeError(`Archive chunk targets unknown table '${table}'`), - }); + return yield* new ArchiveTableValidationError({ table }); } const tableInfo = (yield* sql.unsafe( `PRAGMA main.table_info(${quoteIdentifier(table)})`, @@ -550,10 +574,8 @@ const make = Effect.gen(function* () { [threadId], )) as ReadonlyArray; if (invalidKinds.length > 0) { - return yield* new ArchiveCodecError({ - cause: new TypeError( - `Archive contains unknown chunk kind '${String(invalidKinds[0]?.kind)}'`, - ), + return yield* new ArchiveChunkKindValidationError({ + chunkKind: String(invalidKinds[0]?.kind), }); } @@ -704,7 +726,7 @@ const make = Effect.gen(function* () { )) as ReadonlyArray; yield* Effect.forEach( rows, - (row) => archiveImpl(ThreadId.make(String(row.thread_id)), true), + (row) => archiveImpl(ThreadId.make(String(row.thread_id)), true, Effect.void), { discard: true, }, @@ -861,18 +883,21 @@ const make = Effect.gen(function* () { getTreeSemaphore(threadId), ({ semaphore }) => semaphore.withPermit(effect), releaseTreeSemaphore, - ).pipe(Effect.mapError((cause) => storageError(operation, threadId, cause))); + ).pipe(Effect.mapError((cause) => new ThreadColdStorageError({ operation, threadId, cause }))); const listIds = (query: string, operation: string) => sql.unsafe(query).pipe( Effect.map((rows) => (rows as ReadonlyArray).map((row) => ThreadId.make(String(row.thread_id))), ), - Effect.mapError((cause) => storageError(operation, "startup", cause)), + Effect.mapError( + (cause) => new ThreadColdStorageError({ operation, threadId: "startup", cause }), + ), ); return { - archiveThread: (threadId) => wrap("archive", threadId, archiveImpl(threadId, false)), + archiveThread: (threadId, quiesce = Effect.void) => + wrap("archive", threadId, archiveImpl(threadId, false, quiesce)), restoreTree: (threadId) => wrap("restore", threadId, restoreTreeImpl(threadId)), rollbackRestoreTree: (threadId) => wrap("rollback-restore", threadId, rollbackRestoreTreeImpl(threadId)), @@ -882,7 +907,14 @@ const make = Effect.gen(function* () { removeProviderLogs: (threadId) => wrap("remove-provider-logs", threadId, removeProviderLogsImpl(threadId)), compactLegacyStorage: compactLegacyStorageImpl().pipe( - Effect.mapError((cause) => storageError("compact-legacy-storage", "startup", cause)), + Effect.mapError( + (cause) => + new ThreadColdStorageError({ + operation: "compact-legacy-storage", + threadId: "startup", + cause, + }), + ), ), listPendingArchiveThreadIds: listIds( `SELECT thread_id diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index fdd2e1c60f2..8ccd2730359 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -4,6 +4,7 @@ import { EventId, PreviewSessionLookupError, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, type OrchestrationEvent, @@ -73,7 +74,9 @@ function testReactorLayer(input: { readonly getProjectedSession: ProjectionThreadSessionRepository["Service"]["getByThreadId"]; readonly archiveThread: ThreadColdStorage["Service"]["archiveThread"]; readonly closePreview?: PreviewManager["Service"]["close"]; + readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; readonly pendingArchives?: ReadonlyArray; + readonly runArchiveQuiesce?: boolean; }) { return ThreadDeletionReactorLive.pipe( Layer.provide( @@ -97,7 +100,7 @@ function testReactorLayer(input: { ), Layer.provide( Layer.mock(TerminalManager.TerminalManager)({ - close: () => Effect.void, + close: input.closeTerminal ?? (() => Effect.void), }), ), Layer.provide( @@ -108,7 +111,18 @@ function testReactorLayer(input: { Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provide( Layer.mock(ThreadColdStorage)({ - archiveThread: input.archiveThread, + archiveThread: (threadId, quiesce) => + (input.runArchiveQuiesce === false ? Effect.void : (quiesce ?? Effect.void)).pipe( + Effect.mapError( + (cause) => + new ThreadColdStorageError({ + operation: "archive", + threadId, + cause, + }), + ), + Effect.andThen(input.archiveThread(threadId)), + ), deleteThread: () => Effect.void, compactLegacyStorage: Effect.void, listPendingArchiveThreadIds: Effect.succeed(input.pendingArchives ?? []), @@ -234,6 +248,44 @@ effectIt.effect("closes previews when observing an archive event", () => }), ); +effectIt.effect("leaves stale archive cleanup to cold-storage eligibility", () => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const subscription = yield* PubSub.subscribe(events); + const threadId = ThreadId.make("thread-stale-archive-cleanup"); + const archived = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const terminalCloseCalls = yield* Ref.make(0); + const layer = testReactorLayer({ + eventStream: Stream.fromSubscription(subscription), + stopSession: () => Ref.update(stopCalls, (count) => count + 1), + getBinding: () => + Effect.succeed( + Option.some({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }), + ), + getProjectedSession: () => Effect.succeed(Option.none()), + closeTerminal: () => Ref.update(terminalCloseCalls, (count) => count + 1), + archiveThread: () => Deferred.succeed(archived, undefined).pipe(Effect.asVoid), + runArchiveQuiesce: false, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* PubSub.publish(events, archivedEvent(threadId)); + yield* Deferred.await(archived); + yield* reactor.drain; + + expect(yield* Ref.get(stopCalls)).toBe(0); + expect(yield* Ref.get(terminalCloseCalls)).toBe(0); + }).pipe(Effect.provide(layer)); + }), +); + effectIt.effect("does not close a restored preview from a delayed archive job", () => Effect.gen(function* () { const events = yield* PubSub.unbounded(); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index b27c9bd9edb..72ee75f9b1b 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -155,12 +155,17 @@ const make = Effect.gen(function* () { const { threadId } = job; if (job.type === "archive") { // Archiving must not snapshot or delete hot rows while any active writer - // can still mutate them. A failure leaves the durable archived shell or - // manifest discoverable so startup recovery can retry the boundary. - yield* stopArchiveProviderSession(threadId); - yield* terminalManager.close({ threadId, deleteHistory: true }); - yield* closeProviderLogWritersRequired(threadId); - yield* threadColdStorage.archiveThread(threadId); + // can still mutate them. Cold storage runs this cleanup while holding the + // same tree lock used by unarchive and only after rechecking eligibility, + // so a stale archive job cannot stop a newly active thread. + yield* threadColdStorage.archiveThread( + threadId, + Effect.gen(function* () { + yield* stopArchiveProviderSession(threadId); + yield* terminalManager.close({ threadId, deleteHistory: true }); + yield* closeProviderLogWritersRequired(threadId); + }), + ); return; } yield* stopProviderSession(threadId); diff --git a/apps/server/src/orchestration/Services/ThreadColdStorage.ts b/apps/server/src/orchestration/Services/ThreadColdStorage.ts index 2c0313a8064..738a9eb8ac4 100644 --- a/apps/server/src/orchestration/Services/ThreadColdStorage.ts +++ b/apps/server/src/orchestration/Services/ThreadColdStorage.ts @@ -19,7 +19,10 @@ export class ThreadColdStorageError extends Schema.TaggedErrorClass Effect.Effect; + readonly archiveThread: ( + threadId: ThreadId, + quiesce?: Effect.Effect, + ) => Effect.Effect; readonly restoreTree: (threadId: ThreadId) => Effect.Effect; readonly rollbackRestoreTree: ( threadId: ThreadId,