diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd3..00e99f48470 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -73,7 +73,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..42959281e61 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 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: ```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/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..3511828e038 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,55 @@ +# 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 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. 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 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. +- 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/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` +- `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` + +## Sidebar And Shell Consistency + +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` 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. + +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 + +- Web: `5736` +- Server/WebSocket: `13776` 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..e3974629b17 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -26,11 +26,15 @@ 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 { readonly environmentId: EnvironmentId; @@ -383,6 +387,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; @@ -396,10 +401,13 @@ 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), ); + const onDelete = props.isUnarchiving ? () => undefined : props.onDelete; return ( undefined : props.onUnarchive, }} simultaneousWithExternalGesture={props.simultaneousSwipeGesture} threadTitle={props.thread.title} @@ -434,7 +443,16 @@ function ArchivedThreadRow(props: { }} > - + {props.isUnarchiving ? ( + + ) : ( + + )} @@ -445,9 +463,11 @@ function ArchivedThreadRow(props: { > {props.thread.title} - - {timestamp} - + {timestamp ? ( + + {timestamp} + + ) : null} {subtitle.length > 0 ? ( @@ -500,6 +520,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 +585,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 +603,7 @@ export function ArchivedThreadsScreen(props: { handleSwipeableWillOpen, onDeleteThread, onUnarchiveThread, + props.unarchivingThreadKeys, ], ); const listEmptyComponent = useMemo(() => { diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 697d13e7c47..34527e14924 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"); @@ -143,4 +143,66 @@ 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", () => { + 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..a993f1cd320 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"; @@ -19,15 +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 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>; @@ -72,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, @@ -91,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/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index e200eb7acde..1578d91e55f 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -219,7 +219,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( @@ -230,8 +230,8 @@ export function useArchivedThreadListActions( ); const executeAction = useThreadActionExecutor(handleCompleted); const unarchiveThread = useCallback( - (thread: EnvironmentThreadShell) => { - void executeAction("unarchive", thread); + async (thread: EnvironmentThreadShell) => { + await executeAction("unarchive", thread); }, [executeAction], ); 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 3d50a2dbc55..b1ab509a76f 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", @@ -267,11 +269,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"), 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/apps/server/src/config.ts b/apps/server/src/config.ts index e678264dde5..705c2d9e5c8 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; @@ -107,6 +108,7 @@ export const deriveServerPaths = Effect.fn(function* ( devUrl !== undefined && !options.baseDirIsExplicit ? "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"); @@ -114,6 +116,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.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea783..319fad055c2 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -9,6 +9,7 @@ import { type OrchestrationEvent, ProviderInstanceId, } from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -38,6 +39,7 @@ import { } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; +import { ThreadColdStorage } from "../Services/ThreadColdStorage.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); @@ -868,6 +870,158 @@ 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; + let failNextFinish = false; + + const flakyStore: OrchestrationEventStoreShape = { + append(event) { + if (event.commandId === CommandId.make("cmd-cold-unarchive-fail")) { + return Effect.fail( + new PersistenceSqlError({ + operation: "test.append", + detail: "unarchive append failed", + }), + ); + } + const savedEvent = { ...event, sequence: nextSequence } as StoredEvent; + nextSequence += 1; + events.push(savedEvent); + return Effect.succeed(savedEvent); + }, + readFromSequence(sequenceExclusive) { + return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive)); + }, + readAll() { + return Stream.fromIterable(events); + }, + }; + const coldStorage: ThreadColdStorage["Service"] = { + archiveThread: () => Effect.void, + restoreTree: () => Effect.succeed(restoreOnUnarchive), + rollbackRestoreTree: (threadId) => + Effect.sync(() => { + rolledBackThreadIds.push(threadId); + }), + finishRestoreTree: (threadId) => + Effect.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, + 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"), + }); + + 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( + 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([]); + + 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)); + }); + 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 19184915ac7..4457ac08f34 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); @@ -90,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, @@ -105,6 +119,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => { 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, @@ -135,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, }; @@ -150,6 +175,28 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + 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) { + restoredUnarchiveThreadId = unarchiveThreadId; + commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); + } else { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: "Failed to restore the archived conversation.", + }); + } + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, @@ -212,7 +259,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); + restoredUnarchiveCommitted = restoredUnarchiveThreadId !== null; commandReadModel = committedCommand.nextCommandReadModel; + if (restoredUnarchiveThreadId !== null) { + yield* finishRestoredUnarchiveTree(restoredUnarchiveThreadId); + } for (const [index, event] of committedCommand.committedEvents.entries()) { yield* PubSub.publish(eventPubSub, event); if (index === 0) { @@ -261,6 +312,40 @@ const makeOrchestrationEngine = Effect.gen(function* () { return; } + if ( + restoredUnarchiveThreadId !== null && + !restoredUnarchiveCommitted && + 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 new file mode 100644 index 00000000000..342f0c0e2e5 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.test.ts @@ -0,0 +1,930 @@ +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 Schema from "effect/Schema"; +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 encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +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 + ) 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' + ) + `; +}); + +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("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("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; + 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)); + assert.deepInclude(yield* storage.listPendingArchiveThreadIds, 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} + `; + 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" }]); + + 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("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; + 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; + 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("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; + 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)); + + // 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} + `; + 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} + `; + 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" }]); + }), + ); + + 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* 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"); + yield* sql` + UPDATE cold_archive.archive_thread_chunks + SET kind = 'attachment:../thread-traversal-escape' + 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" }]); + 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', + '[{"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"); + 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); + }), + ); + + 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("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; + 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; + 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* 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"); + + 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 new file mode 100644 index 00000000000..61e3d13b2c6 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadColdStorage.ts @@ -0,0 +1,964 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeUtil from "node:util"; +import * as NodeZlib from "node:zlib"; + +import { ThreadId } from "@t3tools/contracts"; +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 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 { + parseAttachmentIdFromRelativePath, + toSafeThreadAttachmentSegment, +} from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ThreadColdStorage, ThreadColdStorageError } 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; +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; +type ThreadLockEntry = { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; +}; +type AcquiredThreadLock = { + readonly rootThreadId: ThreadId; + readonly semaphore: Semaphore.Semaphore; +}; + +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"], + ["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; + +const THREAD_TABLES = [ + ...ARCHIVED_THREAD_TABLES, + ["orchestration_command_receipts", "aggregate_id"] as const, +] as const; + +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({ operation: "encode", 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({ operation: "decode", cause }), + }), + ), + Effect.mapError((cause) => + cause instanceof ArchiveCodecError + ? cause + : new ArchiveCodecError({ operation: "decode", cause }), + ), + ); +} + +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) => + Effect.tryPromise({ + try: () => gzipAsync(data), + 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({ operation: "decompress", 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; + 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`); + 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 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( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed([] as string[]) : Effect.fail(error), + ), + ); + return entries.filter((entry) => { + if (archivedEntries.has(entry)) return true; + const attachmentId = parseAttachmentIdFromRelativePath(entry); + return attachmentId !== null && attachmentIds.has(attachmentId); + }); + }); + + 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.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed([] as string[]) : Effect.fail(error), + ), + ); + yield* Effect.forEach( + 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 }, + ); + }); + + const reclaimFreePages = Effect.fn("reclaimThreadStorageFreePages")(function* () { + yield* sql.unsafe("PRAGMA main.incremental_vacuum(2048)"); + 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; + 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 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, + quiesce: Effect.Effect, + ) { + 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 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; + } + const rootThreadId = String(source.root_thread_id ?? threadId); + // A restored manifest reserves this archive epoch for an in-flight + // 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; + } + yield* quiesce; + const archivedAt = String( + threadRows[0]?.archived_at ?? 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 ARCHIVED_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 = yield* 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; + } + + // Chunk creation stays retryable outside the hot-row deletion transaction. + // A retry replaces every partial chunk before deleting source data. + 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, + compressed_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + [threadId, rootThreadId, ARCHIVE_VERSION, archivedAt, originalBytes, compressedBytes], + ); + for (const [table, keyColumn] of [...ARCHIVED_THREAD_TABLES].toReversed()) { + yield* sql.unsafe(`DELETE FROM ${table} WHERE ${keyColumn} = ?`, [threadId]); + } + yield* sql.unsafe( + `UPDATE thread_archive_manifests + SET status = 'cleanup_pending', original_bytes = ?, compressed_bytes = ?, + updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE thread_id = ?`, + [originalBytes, compressedBytes, threadId], + ); + return true; + }), + ); + + if (!archivedAtDestructiveBoundary) return; + + yield* completeArchiveCleanup(threadId); + }); + + const insertRows = Effect.fn("restoreArchiveRows")(function* ( + table: string, + rows: ReadonlyArray, + ) { + if (!THREAD_TABLE_NAMES.has(table)) { + return yield* new ArchiveTableValidationError({ 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) { + // 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 ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${placeholders})`, + columns.map((column) => row[column]), + ); + } + }); + + const restoreThread = Effect.fn("restoreArchivedThread")(function* (threadId: ThreadId) { + const bundleRows = (yield* sql.unsafe( + `SELECT 1 AS present FROM ${ARCHIVE_SCHEMA}.archive_threads WHERE thread_id = ? LIMIT 1`, + [threadId], + )) as ReadonlyArray; + 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 ArchiveChunkKindValidationError({ + chunkKind: 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* () { + 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 + SET status = 'restored', updated_at = CURRENT_TIMESTAMP, error = NULL + WHERE thread_id = ?`, + [threadId], + ); + }), + ); + + 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, 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`, + [rootThreadId, rootThreadId], + )) 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")) { + activeRestoreRoots.add(String(rootThreadId)); + 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. + const reserved = 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; + }), + ); + if (reserved) { + activeRestoreRoots.add(String(rootThreadId)); + } + return reserved; + }); + + const rollbackRestoreTreeImpl = Effect.fn("rollbackRestoreArchiveTreeImpl")(function* ( + threadId: ThreadId, + ) { + const rootThreadId = yield* resolveTreeRoot(threadId); + 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, Effect.void), + { + discard: true, + }, + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + activeRestoreRoots.delete(String(rootThreadId)); + }), + ), + ); + }); + + const finishRestoreTreeImpl = Effect.fn("finishRestoreArchiveTreeImpl")(function* ( + threadId: ThreadId, + ) { + const rootThreadId = yield* resolveTreeRoot(threadId); + 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)); + }), + ), + ); + }); + + 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], + ); + // 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( + `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* reclaimFreePages(); + yield* sql.unsafe(`DELETE FROM thread_cleanup_queue WHERE thread_id = ?`, [threadId]); + }); + + 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 getTreeSemaphore = (threadId: ThreadId) => + Effect.flatMap(resolveTreeRoot(threadId), (rootThreadId) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + 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.acquireUseRelease( + getTreeSemaphore(threadId), + ({ semaphore }) => semaphore.withPermit(effect), + releaseTreeSemaphore, + ).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) => new ThreadColdStorageError({ operation, threadId: "startup", cause }), + ), + ); + + return { + 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)), + 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) => + new ThreadColdStorageError({ + operation: "compact-legacy-storage", + threadId: "startup", + cause, + }), + ), + ), + listPendingArchiveThreadIds: listIds( + `SELECT thread_id + FROM ( + SELECT thread_id, archived_at + 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 + 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( + `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"]; +}); + +export const ThreadColdStorageLive = Layer.effect(ThreadColdStorage, make); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 34b1b995a3a..8ccd2730359 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -1,10 +1,136 @@ -import { ThreadId } from "@t3tools/contracts"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + PreviewSessionLookupError, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, +} 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"; +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"; -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 { 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"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ThreadColdStorage, ThreadColdStorageError } from "../Services/ThreadColdStorage.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ThreadColdStorageLive } from "./ThreadColdStorage.ts"; +import { + enqueueLifecycleJobOnce, + 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 closePreview?: PreviewManager["Service"]["close"]; + readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; + readonly pendingArchives?: ReadonlyArray; + readonly runArchiveQuiesce?: boolean; +}) { + 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: input.closeTerminal ?? (() => Effect.void), + }), + ), + Layer.provide( + Layer.mock(PreviewManager)({ + close: input.closePreview ?? (() => Effect.void), + }), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.mock(ThreadColdStorage)({ + 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 ?? []), + listPendingDeleteThreadIds: Effect.succeed([]), + }), + ), + ); +} describe("logCleanupCauseUnlessInterrupted", () => { const threadId = ThreadId.make("thread-deletion-reactor-test"); @@ -36,3 +162,518 @@ 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(); + 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("closes previews when observing an archive event", () => + 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("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(); + 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", + () => + 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("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(); + 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 deleteStarted = yield* Deferred.make(); + const previewCloseCalls = yield* Ref.make>([]); + + const orchestrationEngineLayer = Layer.succeed(OrchestrationEngineService, { + 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), + }); + const terminalLayer = Layer.mock(TerminalManager.TerminalManager)({ + close: () => Effect.void, + }); + const previewLayer = Layer.mock(PreviewManager)({ + close: ({ threadId: closedThreadId }) => + Ref.update(previewCloseCalls, (calls) => [...calls, closedThreadId]), + }); + 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( + Layer.mock(ProviderSessionDirectory)({ + getBinding: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ProjectionThreadSessionRepository)({ + getByThreadId: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide(terminalLayer), + Layer.provide(previewLayer), + 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 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); + + 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* 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} + `; + 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)); + }), +); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7d8a24069a3..72ee75f9b1b 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -2,18 +2,56 @@ 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"; 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 { PreviewManager } from "../../preview/Manager.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 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 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, @@ -39,7 +77,13 @@ 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 previewManager = yield* PreviewManager; 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({ @@ -55,39 +99,166 @@ const make = Effect.gen(function* () { threadId, }); - const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( - event: ThreadDeletedEvent, + 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) => + logger?.closeThread ? [logger.closeThread(threadId)] : [], + ), + { discard: true }, + ); + + const closeProviderLogWriters = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: closeProviderLogWritersRequired(threadId), + message: "thread lifecycle cleanup skipped provider log writer close", + threadId, + }); + + const stopArchiveProviderSession = Effect.fn("stopArchiveProviderSession")(function* ( + threadId: ThreadArchivedEvent["payload"]["threadId"], ) { - const { threadId } = event.payload; + 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, + ) { + if (job.type === "compact-legacy-storage") { + yield* threadColdStorage.compactLegacyStorage; + return; + } + const { threadId } = job; + if (job.type === "archive") { + // Archiving must not snapshot or delete hot rows while any active writer + // 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); yield* closeThreadTerminals(threadId); + yield* closeProviderLogWriters(threadId); + 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), - }); + }).pipe(Effect.andThen(Queue.offer(retryRequested, undefined)), Effect.asVoid); }), ); - const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + const scheduledJobs = new Set(); + const worker = yield* makeDrainableWorker((job: ThreadLifecycleJob) => + processLifecycleJobSafely(job).pipe( + Effect.ensuring( + Effect.sync(() => { + scheduledJobs.delete(lifecycleJobKey(job)); + }), + ), + ), + ); + + const enqueueLifecycleJob = (job: ThreadLifecycleJob) => + enqueueLifecycleJobOnce(scheduledJobs, lifecycleJobKey(job), worker.enqueue(job)); + + const enqueuePendingLifecycleJobs = Effect.fn("enqueuePendingThreadLifecycleJobs")(function* () { + 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.andThen(Queue.offer(retryRequested, undefined)), Effect.as(null)), + ), + ); + if (pendingJobs === null) return; + const [pendingDeletes, pendingArchives] = pendingJobs; + yield* Effect.forEach( + pendingDeletes, + (threadId) => enqueueLifecycleJob({ type: "delete", threadId }), + { discard: true }, + ); + yield* Effect.forEach( + pendingArchives, + (threadId) => enqueueLifecycleJob({ type: "archive", threadId }), + { discard: true }, + ); + }); 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 closeThreadPreviews(event.payload.threadId).pipe( + Effect.andThen( + enqueueLifecycleJob({ type: "delete", threadId: event.payload.threadId }), + ), + ); } - return worker.enqueue(event); + if (event.type === "thread.archived") { + return closeThreadPreviews(event.payload.threadId).pipe( + Effect.andThen( + 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(Queue.poll(retryRequested)), + 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/server/src/orchestration/Services/ThreadColdStorage.ts b/apps/server/src/orchestration/Services/ThreadColdStorage.ts new file mode 100644 index 00000000000..738a9eb8ac4 --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadColdStorage.ts @@ -0,0 +1,45 @@ +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.Defect(), + }, +) { + override get message(): string { + return `Cold thread storage failed during ${this.operation} for '${this.threadId}'.`; + } +} + +export class ThreadColdStorage extends Context.Service< + ThreadColdStorage, + { + readonly archiveThread: ( + threadId: ThreadId, + quiesce?: Effect.Effect, + ) => 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") {} 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/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717f..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,6 +176,80 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { }), ); + it.effect("rejects deleteArchivedThreads when the project still has a live thread", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + 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({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-archived-only-mixed"), + projectId: asProjectId("project-delete"), + deleteArchivedThreads: true, + }, + readModel: withArchivedThread, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("cannot be deleted without force=true"); + }), + ); + + 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()) { + 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({ + 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 100369ae6e3..b37643535ed 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -299,20 +299,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, ); - if (activeThreads.length > 0 && command.force !== true) { + 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/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a9..3c86539f46c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,8 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ThreadColdArchive.ts"; +import Migration0036 from "./Migrations/036_DeletedThreadCleanupQueue.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +95,8 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ThreadColdArchive", Migration0035], + [36, "DeletedThreadCleanupQueue", Migration0036], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_036_ThreadStorageLifecycle.test.ts b/apps/server/src/persistence/Migrations/035_036_ThreadStorageLifecycle.test.ts new file mode 100644 index 00000000000..7b8cff58d54 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_036_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("035-036 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: 36 }); + + 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/035_ThreadColdArchive.ts b/apps/server/src/persistence/Migrations/035_ThreadColdArchive.ts new file mode 100644 index 00000000000..d10e7e5394b --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_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/036_DeletedThreadCleanupQueue.ts b/apps/server/src/persistence/Migrations/036_DeletedThreadCleanupQueue.ts new file mode 100644 index 00000000000..24cc80ec44e --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_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.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 39d93ddc162..2f63457bb56 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -237,6 +237,40 @@ describe("EventNdjsonLogger", () => { }), ); + it.effect("flushes one thread before destructive cleanup without closing the store", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const archivedPath = ownedLogPath(basePath, "thread-archived"); + const activePath = ownedLogPath(basePath, "thread-active"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); + const native = store.logger("native"); + const canonical = store.logger("canonical"); + + yield* native.write({ id: "before-archive" }, ThreadId.make("thread-archived")); + assert.equal(NodeFS.existsSync(archivedPath), false); + + assert.exists(native.closeThread); + if (!native.closeThread) return; + yield* native.closeThread(ThreadId.make("thread-archived")); + assert.equal(NodeFS.existsSync(archivedPath), true); + + yield* canonical.write( + { type: "item.completed", id: "still-active" }, + ThreadId.make("thread-active"), + ); + yield* store.close(); + + assert.include(NodeFS.readFileSync(archivedPath, "utf8"), '"id":"before-archive"'); + assert.include(NodeFS.readFileSync(activePath, "utf8"), '"id":"still-active"'); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + it.effect("flushes an active batch without a permanent polling loop", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 8eb9d11e7a4..d9d9dc2f232 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -51,12 +51,14 @@ export type EventNdjsonStream = "native" | "canonical" | "orchestration"; export interface EventNdjsonLogger { readonly filePath: string; readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + readonly closeThread?: (threadId: ThreadId) => Effect.Effect; readonly close: () => Effect.Effect; } export interface EventNdjsonLogStore { readonly filePath: string; readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; + readonly closeThread: (threadId: ThreadId) => Effect.Effect; readonly close: () => Effect.Effect; } @@ -549,6 +551,17 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( yield* Scope.close(timerScope, Exit.void); }); + const closeThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const threadSegment = resolveThreadSegment(threadId); + yield* flush(false, false); + yield* SynchronizedRef.update(stateRef, (state) => { + if (!state.sinks.has(threadSegment)) return state; + const sinks = new Map(state.sinks); + sinks.delete(threadSegment); + return { ...state, sinks }; + }); + }); + const loggerViews = new Map(); const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { const existing = loggerViews.get(stream); @@ -592,12 +605,17 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( } }); - const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; + const view = { + filePath, + write, + closeThread, + close: () => Effect.void, + } satisfies EventNdjsonLogger; loggerViews.set(stream, view); return view; }; - return { filePath, logger, close } satisfies EventNdjsonLogStore; + return { filePath, logger, closeThread, close } satisfies EventNdjsonLogStore; }); export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( 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.test.ts b/apps/server/src/server.test.ts index 0b233d7d9f7..a9440669557 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5917,6 +5917,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + 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), + latestSequence: Effect.succeed(2), + 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, + requestCompletionMarker: true, + }).pipe(Stream.take(2), 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); + assert.deepEqual(items[1], { kind: "synchronized" }); + }).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]!; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc9..a78c18477db 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -53,6 +53,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"; @@ -331,9 +332,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/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index fbf7c14b738..b5b29769840 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -12,10 +12,12 @@ import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; +import { usePreviewThreadLifecycleCleanup } from "./usePreviewThreadLifecycleCleanup"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); const previewByThreadKey = useActivePreviewSessions(); + usePreviewThreadLifecycleCleanup(); const sessions = useMemo( () => Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { diff --git a/apps/web/src/browser/previewThreadLifecycle.test.ts b/apps/web/src/browser/previewThreadLifecycle.test.ts new file mode 100644 index 00000000000..ecce32c4058 --- /dev/null +++ b/apps/web/src/browser/previewThreadLifecycle.test.ts @@ -0,0 +1,105 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { reconcilePreviewThreadRefs } from "./previewThreadLifecycle"; + +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("reconcilePreviewThreadRefs", () => { + it("finds removed preview state only in live environment shells", () => { + expect( + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, archivedRef, deletedRef], + activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne, environmentTwo]), + liveEnvironmentIds: new Set([environmentOne]), + }), + ).toEqual({ + removedThreadRefs: [archivedRef], + nextActiveThreadRefs: [deletedRef, activeRef], + }); + }); + + it("retains the prior baseline until shell synchronization completes", () => { + const synchronizing = reconcilePreviewThreadRefs({ + previousActiveThreadRefs: [activeRef, archivedRef], + activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne]), + liveEnvironmentIds: new Set(), + }); + expect(synchronizing).toEqual({ + removedThreadRefs: [], + nextActiveThreadRefs: [activeRef, archivedRef], + }); + + expect( + reconcilePreviewThreadRefs({ + previousActiveThreadRefs: synchronizing.nextActiveThreadRefs, + activeThreadRefs: [activeRef], + catalogEnvironmentIds: new Set([environmentOne]), + liveEnvironmentIds: new Set([environmentOne]), + }), + ).toEqual({ + removedThreadRefs: [archivedRef], + nextActiveThreadRefs: [activeRef], + }); + }); + + 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("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({ + 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 new file mode 100644 index 00000000000..f3efa921240 --- /dev/null +++ b/apps/web/src/browser/previewThreadLifecycle.ts @@ -0,0 +1,42 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; + +interface ReconcilePreviewThreadRefsInput { + readonly previousActiveThreadRefs: readonly ScopedThreadRef[]; + readonly activeThreadRefs: readonly ScopedThreadRef[]; + readonly catalogEnvironmentIds: ReadonlySet | null; + readonly liveEnvironmentIds: ReadonlySet; +} + +interface ReconcilePreviewThreadRefsResult { + readonly removedThreadRefs: readonly ScopedThreadRef[]; + readonly nextActiveThreadRefs: readonly ScopedThreadRef[]; +} + +export function reconcilePreviewThreadRefs( + input: ReconcilePreviewThreadRefsInput, +): ReconcilePreviewThreadRefsResult { + const activeThreadKeys = new Set(input.activeThreadRefs.map(scopedThreadKey)); + const removedThreadRefs = input.previousActiveThreadRefs.filter( + (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.catalogEnvironmentIds === null || + input.catalogEnvironmentIds.has(threadRef.environmentId)) && + !input.liveEnvironmentIds.has(threadRef.environmentId), + ), + ...input.activeThreadRefs.filter( + (threadRef) => + (input.catalogEnvironmentIds === null || + input.catalogEnvironmentIds.has(threadRef.environmentId)) && + input.liveEnvironmentIds.has(threadRef.environmentId), + ), + ]; + return { removedThreadRefs, nextActiveThreadRefs }; +} 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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 45b5e117600..3ae414bbae6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -106,7 +106,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"; @@ -114,6 +118,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, @@ -506,6 +511,13 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + 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([]); @@ -571,10 +583,10 @@ function OpenCommandPaletteDialog(props: { () => sortLogicalProjectsForSidebar( unsortedProjectGroups, - threads, + visibleThreads, clientSettings.sidebarProjectSortOrder, ), - [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups], + [clientSettings.sidebarProjectSortOrder, unsortedProjectGroups, visibleThreads], ); const contextualProjectRef = useMemo( () => @@ -811,15 +823,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, ); @@ -840,7 +850,7 @@ function OpenCommandPaletteDialog(props: { handleNewThread, navigate, projectGroupByTargetKey, - threads, + visibleThreads, ], ); @@ -909,7 +919,7 @@ function OpenCommandPaletteDialog(props: { const allThreadItems = useMemo( () => buildThreadActionItems({ - threads, + threads: visibleThreads, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, sortOrder: clientSettings.sidebarThreadSortOrder, @@ -923,7 +933,13 @@ function OpenCommandPaletteDialog(props: { }); }, }), - [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], + [ + activeThreadId, + clientSettings.sidebarThreadSortOrder, + navigate, + projectTitleById, + visibleThreads, + ], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -1431,7 +1447,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, ); @@ -1520,7 +1536,7 @@ function OpenCommandPaletteDialog(props: { providers, setOpen, clientSettings.sidebarThreadSortOrder, - threads, + visibleThreads, ], ); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..8086dfafe35 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildArchivedProjectRemovalPlans, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + filterVisibleSidebarThreads, + getArchivedProjectRemovalWarning, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -36,7 +39,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; - +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -1092,6 +1095,97 @@ 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("archived project removal with grouped project actions", () => { + it("derives archived-bundle command scope from each project member's threads", () => { + const members = [ + { environmentId: "environment-live", id: "grouped-project" }, + { environmentId: "environment-archived", id: "grouped-project" }, + ]; + const projectThreads = [ + { + environmentId: "environment-live", + projectId: "grouped-project", + id: "thread-live", + }, + ]; + + expect( + buildArchivedProjectRemovalPlans(members, projectThreads).map((plan) => ({ + environmentId: plan.member.environmentId, + threadIds: plan.memberThreads.map((thread) => thread.id), + options: plan.commandOptions, + })), + ).toEqual([ + { + environmentId: "environment-live", + threadIds: ["thread-live"], + options: { force: true }, + }, + { + environmentId: "environment-archived", + threadIds: [], + options: { 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: true, + }), + ).toBe( + "This permanently clears conversation history for those threads and any archived conversations in these projects.", + ); + 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({ @@ -1386,6 +1480,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", () => { @@ -1423,4 +1558,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 7aee3100d0e..719f0e1424d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,7 @@ 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 { getThreadSortTimestamp, sortThreads, @@ -30,8 +31,9 @@ type ScopedSidebarProject = SidebarProject & { }; type ScopedSidebarThread = ThreadSortInput & { - environmentId: string; - projectId: string; + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; archivedAt: string | null; }; @@ -671,6 +673,66 @@ 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 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 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: { @@ -764,6 +826,7 @@ export function sortLogicalProjectsForSidebar< projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, + optimisticallyArchivedThreadKeys: ReadonlySet = new Set(), ): TProject[] { const groupKeyByProjectRef = new Map( projects.flatMap((project) => @@ -775,7 +838,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); @@ -807,12 +877,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/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98b5dcf84ed..bda9d80fa73 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -89,6 +89,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, @@ -170,6 +171,7 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { openCommandPalette } from "../commandPaletteBus"; import { + filterVisibleSidebarThreads, archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, @@ -1164,6 +1166,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( @@ -1254,7 +1259,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + filterVisibleSidebarThreads(projectThreads, optimisticallyArchivedThreadKeys), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -1267,7 +1272,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) { @@ -2989,6 +2994,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); @@ -3260,8 +3268,8 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], + () => filterVisibleSidebarThreads(sidebarThreads, optimisticallyArchivedThreadKeys), + [optimisticallyArchivedThreadKeys, sidebarThreads], ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ @@ -3299,8 +3307,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, ); @@ -3336,6 +3345,7 @@ export default function Sidebar() { sidebarThreadSortOrder, sidebarThreadPreviewCount, expandedThreadListsByProject, + optimisticallyArchivedThreadKeys, projectExpandedById, routeThreadKey, sortedProjects, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 33c2512abf7..a906d6fb0a8 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -70,6 +70,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; +import { useOptimisticThreadArchiveStore } from "../optimisticThreadArchiveStore"; import { deriveProjectGroupingOverrideKey, getProjectOrderKey, @@ -106,6 +107,9 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + buildArchivedProjectRemovalPlans, + filterVisibleSidebarThreads, + getArchivedProjectRemovalWarning, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -1055,6 +1059,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); @@ -1185,8 +1192,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( @@ -1292,6 +1305,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; @@ -1308,7 +1322,10 @@ export default function SidebarV2() { : []), ] : [`This removes ${members.length} grouped project entries.`]), - "This permanently clears conversation history for those threads.", + 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.", @@ -1324,9 +1341,14 @@ export default function SidebarV2() { : []), ] : [`This removes ${members.length} grouped project entries.`]), + 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.", + "This action cannot be undone.", ].join("\n"), ), ); @@ -1334,11 +1356,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({ @@ -1351,7 +1369,7 @@ export default function SidebarV2() { environmentId: project.environmentId, input: { projectId: project.id, - ...(memberThreads.length > 0 ? { force: true } : {}), + ...commandOptions, }, }); if (result._tag === "Failure") { @@ -1437,8 +1455,9 @@ export default function SidebarV2() { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot - // merging, no optimistic holds. Archived threads remain hidden here — - // archive keeps its original "remove from sidebar" meaning. + // merging. Archived threads remain hidden here — including while a local + // archive command is still in flight — because archive keeps its original + // "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; @@ -1448,11 +1467,10 @@ export default function SidebarV2() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - const visible = threads.filter( + const visible = filterVisibleSidebarThreads(threads, optimisticallyArchivedThreadKeys).filter( (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -1497,6 +1515,7 @@ export default function SidebarV2() { autoSettleAfterDays, changeRequestStateByKey, nowMinute, + optimisticallyArchivedThreadKeys, scopedProjectKeys, serverConfigs, snoozeWakeTick, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4407e621525..9ac31799377 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/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 5385751924e..e0cd55c204d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,12 +1,4 @@ -import { - ArchiveIcon, - ArchiveX, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - SettingsIcon, -} from "lucide-react"; +import { InfoIcon, LoaderIcon, PlusIcon, RefreshCwIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -20,14 +12,11 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, - type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, - settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { @@ -65,7 +54,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, @@ -83,9 +71,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 { Dialog, @@ -136,7 +122,6 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; -import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ @@ -2204,226 +2189,3 @@ export function ProviderSettingsPanel() { ); } - -export function ArchivedThreadsPanel() { - const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); - 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 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 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.", - }), - ); - } - 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, refreshArchivedThreads, unarchiveThread], - ); - - 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) => ( - { - event.preventDefault(); - 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/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 6d503387bb6..610683e038f 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,57 @@ 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(); + 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 5da93e3f5c5..b85fc8073fd 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), @@ -116,6 +117,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 > 0 && 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") { @@ -125,22 +152,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/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 91a3779a057..2b378d5435e 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -33,6 +33,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", @@ -175,13 +179,20 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; + optimisticallyHideArchivedThread(threadRef); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); 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); opts.onArchived?.(); @@ -202,6 +213,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); +} 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" }); + }); }); 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(() => { 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, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 552468e04d9..2d79562de69 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/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, }; 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)) { diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 3927adcfe29..8de77f9e27c 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -13,7 +13,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`. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 8013a135896..ff71f0fb3a4 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -171,6 +171,9 @@ interface SubscriptionOptions { cause: Cause.Cause>, ) => Effect.Effect; readonly retryExpectedFailureAfter?: Duration.Input; + readonly shouldRetryExpectedFailure?: ( + cause: Cause.Cause>, + ) => boolean; readonly resubscribe?: Stream.Stream; } @@ -248,7 +251,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/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 62d2a2c28b9..a148f1fcfb2 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"), @@ -150,7 +157,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, @@ -161,23 +168,25 @@ 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 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; 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); @@ -197,8 +206,10 @@ describe("environment shell synchronization", () => { loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), saveShell: () => Effect.void, loadThread: () => Effect.succeed(Option.none()), - saveThread: () => Effect.void, - removeThread: () => 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()), saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), @@ -207,6 +218,10 @@ describe("environment shell synchronization", () => { clearVcsRefs: () => 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( @@ -219,24 +234,151 @@ 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); + 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", + 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( + [], + ); + expect(yield* Ref.get(removedThreads)).toEqual([ + "stale-thread", + "archived-after-http-snapshot", + ]); + }), + ); + + 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, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => 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); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 6ccb11797f5..32e083b5f2a 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, reviveCachedThread } from "./threadCache.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -71,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* ( @@ -160,6 +163,52 @@ 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) => + snapshot.threads + .filter((thread) => !nextThreadIds.has(thread.id)) + .map((thread) => thread.id), + }); + const addedThreadIds = Option.match(current.snapshot, { + onNone: () => [] as ReadonlyArray, + onSome: () => + nextSnapshot.threads + .filter((thread) => !currentThreadIds.has(thread.id)) + .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. + // 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, + (threadId) => reviveCachedThread(cache, environmentId, threadId), + { discard: true }, + ); + const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.set(state, { snapshot: Option.some(nextSnapshot), @@ -204,9 +253,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 } : {}), }; } 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, diff --git a/packages/client-runtime/src/state/threadCache.ts b/packages/client-runtime/src/state/threadCache.ts new file mode 100644 index 00000000000..2267bbef48a --- /dev/null +++ b/packages/client-runtime/src/state/threadCache.ts @@ -0,0 +1,215 @@ +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; + retainers: number; + operations: number; + readonly lock: Semaphore.Semaphore; +} + +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 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 existing = threadEntries.get(threadId); + if (existing !== undefined) { + return existing; + } + + const created: ThreadCacheState = { + generation: 0, + evicted: false, + retainers: 0, + operations: 0, + lock: Semaphore.makeUnsafe(1), + }; + 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 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, + snapshot: Parameters[1], + generation: number, +) { + const threadId = snapshot.thread.id; + 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), + }), + ), + ), + ); + }), + ), + ); +}); + +export const evictCachedThread = Effect.fn("EnvironmentThreadCache.evict")(function* ( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +) { + return yield* withThreadCacheState(cache, environmentId, threadId, (state) => + state.lock.withPermit( + Effect.gen(function* () { + state.generation += 1; + state.evicted = true; + return yield* cache.removeThread(environmentId, threadId).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("Could not evict cached thread detail.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + ...safeErrorLogAttributes(error), + }), + Effect.as(false), + ), + ), + ); + }), + ), + ); +}); + +export const reviveCachedThread = Effect.fn("EnvironmentThreadCache.revive")(function* ( + cache: EnvironmentCacheStore["Service"], + environmentId: EnvironmentId, + threadId: ThreadId, +) { + yield* withThreadCacheState(cache, environmentId, threadId, (state) => + 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/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts new file mode 100644 index 00000000000..3a78a7d277b --- /dev/null +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -0,0 +1,151 @@ +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, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => 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 6c128eb01ab..7c7cf4c2898 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, @@ -60,8 +64,24 @@ 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; + // 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; + }), + ); +}); + export function createThreadEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const scheduler = createAtomCommandScheduler(); const concurrency = { @@ -84,7 +104,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, }), diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 43cdc1590bf..04e4e736538 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -36,6 +36,12 @@ import { ThreadSnapshotLoader, type EnvironmentThreadState, } from "./threads.ts"; +import { + cachedThreadGeneration, + evictCachedThread, + persistCachedThread, + reviveCachedThread, +} from "./threadCache.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -250,6 +256,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o supervisorSession, savedThreads, removedThreads, + cache, wakeups, replaceSession: SubscriptionRef.set( supervisorSession, @@ -314,6 +321,47 @@ 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", + }, + }, +}); + +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* () { @@ -455,6 +503,242 @@ 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)).toEqual([]); + return harness.savedThreads; + }), + ); + + expect(yield* Ref.get(savedThreads)).toEqual([]); + }), + ); + + 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 = { + ...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( + 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("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("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 }); + 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("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 196229cc8b1..839baabb176 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -22,6 +22,14 @@ 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, + isCachedThreadEvicted, + persistCachedThread, + retainCachedThread, + reviveCachedThread, +} from "./threadCache.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; @@ -45,7 +53,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* ( @@ -56,6 +64,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( @@ -80,22 +91,16 @@ 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 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* persistCachedThread(cache, environmentId, pending.snapshot, pending.generation); }); yield* Stream.fromQueue(persistence).pipe( @@ -104,6 +109,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); + const removeCachedThread = Effect.fn("EnvironmentThreadState.removeCachedThread")(function* () { + yield* evictCachedThread(cache, environmentId, threadId); + }); + const setSynchronizing = SubscriptionRef.update(state, (current) => current.status === "deleted" ? current @@ -155,7 +164,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 = cachedThreadGeneration(cache, environmentId, threadId); + yield* Queue.offer(persistence, { + generation, + snapshot: { snapshotSequence, thread }, + }); } }); @@ -166,17 +179,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* ( @@ -194,6 +197,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); + if (item.snapshot.thread.archivedAt !== null) { + yield* removeCachedThread(); + } else { + yield* reviveCachedThread(cache, environmentId, threadId); + } yield* setThread(item.snapshot.thread); return; } @@ -213,7 +221,13 @@ 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(); + } } else if (result.kind === "deleted") { yield* setDeleted(); } @@ -293,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)), @@ -304,7 +323,12 @@ 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) + ? persist({ + generation: cachedThreadGeneration(cache, environmentId, threadId), + snapshot: { snapshotSequence, thread }, + }) + : Effect.void, }), ), ), diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 1ebc23a483b..45a0dc3855c 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -158,6 +158,21 @@ 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", + }); + + if (parsed.type !== "project.delete") { + assert.fail(`Expected project.delete, received ${parsed.type}`); + } + assert.strictEqual(parsed.deleteArchivedThreads, undefined); + }), +); + it.effect("decodes historical project.created payloads with a default provider", () => Effect.gen(function* () { const parsed = yield* decodeProjectCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b947bd63e4c..e23fa0dabfd 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -538,6 +538,7 @@ const ProjectDeleteCommand = Schema.Struct({ commandId: CommandId, projectId: ProjectId, force: Schema.optional(Schema.Boolean), + deleteArchivedThreads: Schema.optional(Schema.Boolean), }); const ThreadCreateCommand = Schema.Struct({