From 6e9836afbc4ba66c8ffa84753e1acad82ad277ec Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Thu, 23 Jul 2026 14:45:57 +0300 Subject: [PATCH 1/3] fix(archive): preserve archived task metadata Recover metadata for older archives and keep large archive lists responsive. Generated-By: PostHog Code Task-Id: 68800b78-554e-42ce-9077-b5125e27a6aa --- .../core/src/archive/archiveListView.test.ts | 30 ++ packages/core/src/archive/archiveListView.ts | 24 +- .../host-router/src/routers/archive.router.ts | 2 +- packages/shared/src/archive-domain.ts | 4 + .../features/archive/ArchivedTasksView.tsx | 280 +++++++++++------- .../ui/src/features/archive/useArchiveTask.ts | 19 +- .../migrations/0022_archive_task_details.sql | 11 + .../src/db/migrations/meta/_journal.json | 7 + .../repositories/archive-repository.mock.ts | 14 + .../src/db/repositories/archive-repository.ts | 21 ++ .../task-metadata-repository.mock.ts | 12 + .../repositories/task-metadata-repository.ts | 3 + packages/workspace-server/src/db/schema.ts | 6 + .../services/archive/archive-recovery.test.ts | 91 ++++++ .../src/services/archive/archive-recovery.ts | 110 +++++++ .../archive/archive.integration.test.ts | 103 ++++++- .../src/services/archive/archive.ts | 126 +++++++- .../src/services/archive/schemas.ts | 7 + 18 files changed, 751 insertions(+), 119 deletions(-) create mode 100644 packages/workspace-server/src/db/migrations/0022_archive_task_details.sql create mode 100644 packages/workspace-server/src/services/archive/archive-recovery.test.ts create mode 100644 packages/workspace-server/src/services/archive/archive-recovery.ts diff --git a/packages/core/src/archive/archiveListView.test.ts b/packages/core/src/archive/archiveListView.test.ts index f7b6d83dd7..4ad23175e5 100644 --- a/packages/core/src/archive/archiveListView.test.ts +++ b/packages/core/src/archive/archiveListView.test.ts @@ -6,6 +6,7 @@ import { deriveUniqueRepos, filterAndSortArchivedTasks, getRepoName, + mergeArchivedWithTasks, withRepoNames, } from "./archiveListView"; @@ -58,6 +59,17 @@ describe("deriveUniqueRepos", () => { }); }); +describe("mergeArchivedWithTasks", () => { + it("resolves details fetched directly for an archived task", () => { + const archived = makeArchived("older-task", "2024-01-02T00:00:00.000Z"); + const task = makeTask("older-task", { title: "Recover me" }); + + expect(mergeArchivedWithTasks([archived], [task])).toEqual([ + { archived, task }, + ]); + }); +}); + describe("filterAndSortArchivedTasks", () => { const items: ArchivedTaskWithDetails[] = [ { @@ -79,6 +91,15 @@ describe("filterAndSortArchivedTasks", () => { expect(result.map((i) => i.archived.taskId)).toEqual(["b"]); }); + it("filters by task id when the original title is unavailable", () => { + const result = filterAndSortArchivedTasks(withRepoNames(items), { + searchQuery: "b", + repoFilter: null, + sort: { column: "archived", direction: "desc" }, + }); + expect(result.map((i) => i.archived.taskId)).toEqual(["b"]); + }); + it("filters by repo name", () => { const result = filterAndSortArchivedTasks(withRepoNames(items), { searchQuery: "", @@ -88,6 +109,15 @@ describe("filterAndSortArchivedTasks", () => { expect(result.map((i) => i.archived.taskId)).toEqual(["a"]); }); + it("does not include repository names in the title and task ID search", () => { + const result = filterAndSortArchivedTasks(withRepoNames(items), { + searchQuery: "two", + repoFilter: null, + sort: { column: "archived", direction: "desc" }, + }); + expect(result).toEqual([]); + }); + it("sorts by archivedAt descending", () => { const result = filterAndSortArchivedTasks(withRepoNames(items), { searchQuery: "", diff --git a/packages/core/src/archive/archiveListView.ts b/packages/core/src/archive/archiveListView.ts index 83a58f8736..6a785c39ee 100644 --- a/packages/core/src/archive/archiveListView.ts +++ b/packages/core/src/archive/archiveListView.ts @@ -2,9 +2,14 @@ import type { ArchivedTask } from "@posthog/shared"; import { formatRelativeTimeLong } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +export type ArchivedTaskDetails = Pick< + Task, + "id" | "title" | "created_at" | "repository" +>; + export interface ArchivedTaskWithDetails { archived: ArchivedTask; - task: Task | null; + task: ArchivedTaskDetails | null; } export interface ArchivedTaskWithRepo extends ArchivedTaskWithDetails { @@ -27,12 +32,21 @@ export interface ArchiveFilterSortInput { export function mergeArchivedWithTasks( archivedTasks: ArchivedTask[], - tasks: Task[], + tasks: ArchivedTaskDetails[], ): ArchivedTaskWithDetails[] { const taskMap = new Map(tasks.map((task) => [task.id, task])); return archivedTasks.map((archived) => ({ archived, - task: taskMap.get(archived.taskId) ?? null, + task: + taskMap.get(archived.taskId) ?? + (archived.title && archived.taskCreatedAt + ? { + id: archived.taskId, + title: archived.title, + created_at: archived.taskCreatedAt, + repository: archived.repository, + } + : null), })); } @@ -81,7 +95,9 @@ export function filterAndSortArchivedTasks( const query = searchQuery.trim().toLowerCase(); if (query) { result = result.filter((item) => - (item.task?.title?.toLowerCase() ?? "").includes(query), + [item.task?.title, item.archived.taskId].some((value) => + value?.toLowerCase().includes(query), + ), ); } diff --git a/packages/host-router/src/routers/archive.router.ts b/packages/host-router/src/routers/archive.router.ts index e922d8d4ea..c525f29199 100644 --- a/packages/host-router/src/routers/archive.router.ts +++ b/packages/host-router/src/routers/archive.router.ts @@ -32,7 +32,7 @@ export const archiveRouter = router({ list: publicProcedure .output(listArchivedTasksOutput) .query(({ ctx }) => - ctx.container.get(ARCHIVE_SERVICE).getArchivedTasks(), + ctx.container.get(ARCHIVE_SERVICE).listArchivedTasks(), ), archivedTaskIds: publicProcedure diff --git a/packages/shared/src/archive-domain.ts b/packages/shared/src/archive-domain.ts index dd97947839..7190d0cc12 100644 --- a/packages/shared/src/archive-domain.ts +++ b/packages/shared/src/archive-domain.ts @@ -12,6 +12,10 @@ export const archivedTaskSchema = z.object({ worktreeName: z.string().nullable(), branchName: z.string().nullable(), checkpointId: z.string().nullable(), + title: z.string().nullable().optional(), + taskCreatedAt: z.string().nullable().optional(), + repository: z.string().nullable().optional(), + recoveryPending: z.boolean().optional(), }); export type ArchivedTask = z.infer; diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx index 4180f1a1a1..9e91c3a622 100644 --- a/packages/ui/src/features/archive/ArchivedTasksView.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -33,12 +33,13 @@ import { TextField, } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useMemo, useRef, useState } from "react"; import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner"; import { Tooltip } from "../../primitives/Tooltip"; import { toast } from "../../primitives/toast"; -import { useTasks } from "../tasks/useTasks"; +import { useTaskSummaries, useTasks } from "../tasks/useTasks"; import { useUnarchiveTask } from "./useUnarchiveTask"; const ICON_SIZE = 12; @@ -110,7 +111,7 @@ function SortableColumnHeader({ const filterItemClassName = "flex w-full items-center justify-between rounded-sm px-1.5 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3"; -function RepositoryFilterHeader({ +function RepositoryFilter({ repos, selectedRepo, onSelect, @@ -119,55 +120,59 @@ function RepositoryFilterHeader({ selectedRepo: string | null; onSelect: (repo: string | null) => void; }) { + const [open, setOpen] = useState(false); + const selectRepo = (repo: string | null) => { + onSelect(repo); + setOpen(false); + }; + return ( - - - + + + + + + - - - + {repos.map((repo) => ( - {repos.map((repo) => ( - - ))} - - - - + ))} + + + ); } @@ -206,8 +211,12 @@ export function ArchivedTasksViewPresentation({ }); const [repoFilter, setRepoFilter] = useState(null); const [deleteTargetId, setDeleteTargetId] = useState(null); + const tableViewportRef = useRef(null); + + const resetTableScroll = () => tableViewportRef.current?.scrollTo({ top: 0 }); const handleSort = (column: SortColumn) => { + resetTableScroll(); setSort((prev) => prev.column === column ? { column, direction: prev.direction === "asc" ? "desc" : "asc" } @@ -231,27 +240,52 @@ export function ArchivedTasksViewPresentation({ }), [itemsWithRepo, searchQuery, repoFilter, sort], ); + const rowVirtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => tableViewportRef.current, + estimateSize: () => 37, + overscan: 12, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + const topSpacerHeight = virtualRows[0]?.start ?? 0; + const bottomSpacerHeight = + rowVirtualizer.getTotalSize() - + (virtualRows[virtualRows.length - 1]?.end ?? 0); return ( - - + + setSearchQuery(e.target.value)} - className="text-[13px]" + onChange={(e) => { + resetTableScroll(); + setSearchQuery(e.target.value); + }} + className="min-w-0 flex-1 text-[13px]" > - + { + resetTableScroll(); + setRepoFilter(repo); + }} + /> + + + {isLoading ? ( @@ -289,66 +323,82 @@ export function ArchivedTasksViewPresentation({ onSort={handleSort} width="15%" /> - + + Repository + - {filteredItems.map((item) => ( - onContextMenu(item, e)} - className="group" - > - - - - - {item.task?.title ?? "Unknown task"} + {topSpacerHeight > 0 && ( + + )} + {virtualRows.map((virtualRow) => { + const item = filteredItems[virtualRow.index]; + return ( + onContextMenu(item, e)} + className="group" + > + + + + + {item.task?.title ?? "Unknown task"} + + + + + + {formatRelativeDate(item.task?.created_at)} + + + + + {formatRelativeDate(item.archived.archivedAt)} + + + + + {item.repoName} - - - - - {formatRelativeDate(item.task?.created_at)} - - - - - {formatRelativeDate(item.archived.archivedAt)} - - - - - {item.repoName} - - - - - - - - + + + + + + + + + ); + })} + {bottomSpacerHeight > 0 && ( + - ))} + )} )} @@ -434,10 +484,18 @@ export function ArchivedTasksViewPresentation({ export function ArchivedTasksView() { const trpc = useHostTRPC(); - const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery( - trpc.archive.list.queryOptions(), + const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery({ + ...trpc.archive.list.queryOptions(), + refetchInterval: (query) => + query.state.data?.some((task) => task.recoveryPending) ? 1_000 : false, + }); + const { data: listedTasks = [] } = useTasks(); + const archivedTaskIds = useMemo( + () => archivedTasks.map((task) => task.taskId), + [archivedTasks], ); - const { data: tasks = [], isLoading: isLoadingTasks } = useTasks(); + const { data: archivedTaskDetails = [], isLoading: isLoadingTasks } = + useTaskSummaries(archivedTaskIds); const { restore, remove, runContextMenuAction } = useUnarchiveTask(); useSetHeaderContent( @@ -448,8 +506,12 @@ export function ArchivedTasksView() { useState(null); const items = useMemo( - () => mergeArchivedWithTasks(archivedTasks, tasks), - [archivedTasks, tasks], + () => + mergeArchivedWithTasks(archivedTasks, [ + ...listedTasks, + ...archivedTaskDetails, + ]), + [archivedTasks, listedTasks, archivedTaskDetails], ); const isLoading = isLoadingArchived || isLoadingTasks; @@ -459,8 +521,8 @@ export function ArchivedTasksView() { const task = outcome.navigateToTaskId === null ? null - : (items.find((i) => i.archived.taskId === outcome.navigateToTaskId) - ?.task ?? null); + : (listedTasks.find((item) => item.id === outcome.navigateToTaskId) ?? + null); toast.success("Task unarchived", { action: task ? { diff --git a/packages/ui/src/features/archive/useArchiveTask.ts b/packages/ui/src/features/archive/useArchiveTask.ts index a83828320d..7934157f3b 100644 --- a/packages/ui/src/features/archive/useArchiveTask.ts +++ b/packages/ui/src/features/archive/useArchiveTask.ts @@ -16,6 +16,7 @@ import { type HostTrpcClient, } from "@posthog/host-router/client"; import { useHostTRPC } from "@posthog/host-router/react"; +import type { Task } from "@posthog/shared/domain-types"; import { useReviewViewedStore } from "@posthog/ui/features/code-review/reviewViewedStore"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFocusStore } from "@posthog/ui/features/focus/focusStore"; @@ -126,8 +127,22 @@ function makeOrchestrationDeps( resolveService(SESSION_SERVICE).disconnectFromTask( taskId, ), - archive: (taskId) => - hostClient.archive.archive.mutate({ taskId }).then(() => undefined), + archive: (taskId) => { + const task = queryClient + .getQueriesData({ + queryKey: ["tasks", "list"], + }) + .flatMap(([, tasks]) => tasks ?? []) + .find((item) => item.id === taskId); + return hostClient.archive.archive + .mutate({ + taskId, + title: task?.title, + taskCreatedAt: task?.created_at, + repository: task?.repository, + }) + .then(() => undefined); + }, clearViewedState: (taskId) => useReviewViewedStore.getState().clearTasks([taskId]), logError: (message, error) => log.error(message, error), diff --git a/packages/workspace-server/src/db/migrations/0022_archive_task_details.sql b/packages/workspace-server/src/db/migrations/0022_archive_task_details.sql new file mode 100644 index 0000000000..8b8e32b168 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0022_archive_task_details.sql @@ -0,0 +1,11 @@ +ALTER TABLE `archives` ADD `title` text; +--> statement-breakpoint +ALTER TABLE `archives` ADD `task_created_at` text; +--> statement-breakpoint +ALTER TABLE `archives` ADD `repository` text; +--> statement-breakpoint +ALTER TABLE `task_metadata` ADD `archived_title` text; +--> statement-breakpoint +ALTER TABLE `task_metadata` ADD `archived_task_created_at` text; +--> statement-breakpoint +ALTER TABLE `task_metadata` ADD `archived_repository` text; diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index 9e3a4197c0..c36e3f08f0 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1783956659993, "tag": "0021_famous_bloodaxe", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1784804000000, + "tag": "0022_archive_task_details", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories/archive-repository.mock.ts b/packages/workspace-server/src/db/repositories/archive-repository.mock.ts index 9cc8a8beb2..ef8634a8a0 100644 --- a/packages/workspace-server/src/db/repositories/archive-repository.mock.ts +++ b/packages/workspace-server/src/db/repositories/archive-repository.mock.ts @@ -37,6 +37,9 @@ export function createMockArchiveRepository( workspaceId: data.workspaceId, branchName: data.branchName, checkpointId: data.checkpointId, + title: data.title ?? null, + taskCreatedAt: data.taskCreatedAt ?? null, + repository: data.repository ?? null, archivedAt: now, createdAt: now, updatedAt: now, @@ -45,6 +48,17 @@ export function createMockArchiveRepository( workspaceIndex.set(archive.workspaceId, archive.id); return archive; }, + updateDetailsByWorkspaceId: (workspaceId, details) => { + const id = workspaceIndex.get(workspaceId); + const archive = id ? archives.get(id) : undefined; + if (archive) { + archives.set(archive.id, { + ...archive, + ...details, + updatedAt: new Date().toISOString(), + }); + } + }, deleteByWorkspaceId: (workspaceId: string) => { if (opts?.failOnDelete) { throw new Error("Injected failure on archive delete"); diff --git a/packages/workspace-server/src/db/repositories/archive-repository.ts b/packages/workspace-server/src/db/repositories/archive-repository.ts index 0307afdaaa..e62e1743db 100644 --- a/packages/workspace-server/src/db/repositories/archive-repository.ts +++ b/packages/workspace-server/src/db/repositories/archive-repository.ts @@ -11,6 +11,9 @@ export interface CreateArchiveData { workspaceId: string; branchName: string | null; checkpointId: string | null; + title?: string | null; + taskCreatedAt?: string | null; + repository?: string | null; } export interface IArchiveRepository { @@ -18,6 +21,10 @@ export interface IArchiveRepository { findByWorkspaceId(workspaceId: string): Archive | null; findAll(): Archive[]; create(data: CreateArchiveData): Archive; + updateDetailsByWorkspaceId( + workspaceId: string, + details: Pick, + ): void; deleteByWorkspaceId(workspaceId: string): void; deleteAll(): void; } @@ -60,6 +67,9 @@ export class ArchiveRepository implements IArchiveRepository { workspaceId: data.workspaceId, branchName: data.branchName, checkpointId: data.checkpointId, + title: data.title ?? null, + taskCreatedAt: data.taskCreatedAt ?? null, + repository: data.repository ?? null, archivedAt: timestamp, createdAt: timestamp, updatedAt: timestamp, @@ -72,6 +82,17 @@ export class ArchiveRepository implements IArchiveRepository { return created; } + updateDetailsByWorkspaceId( + workspaceId: string, + details: Pick, + ): void { + this.db + .update(archives) + .set({ ...details, updatedAt: now() }) + .where(byWorkspaceId(workspaceId)) + .run(); + } + deleteByWorkspaceId(workspaceId: string): void { this.db.delete(archives).where(byWorkspaceId(workspaceId)).run(); } diff --git a/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts b/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts index 7e94a4e935..0d20965e9e 100644 --- a/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts +++ b/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts @@ -33,6 +33,18 @@ export function createMockTaskMetadataRepository(): MockTaskMetadataRepository { "archivedAt" in patch ? (patch.archivedAt ?? null) : (existing?.archivedAt ?? null), + archivedTitle: + "archivedTitle" in patch + ? (patch.archivedTitle ?? null) + : (existing?.archivedTitle ?? null), + archivedTaskCreatedAt: + "archivedTaskCreatedAt" in patch + ? (patch.archivedTaskCreatedAt ?? null) + : (existing?.archivedTaskCreatedAt ?? null), + archivedRepository: + "archivedRepository" in patch + ? (patch.archivedRepository ?? null) + : (existing?.archivedRepository ?? null), piSessionFile: "piSessionFile" in patch ? (patch.piSessionFile ?? null) diff --git a/packages/workspace-server/src/db/repositories/task-metadata-repository.ts b/packages/workspace-server/src/db/repositories/task-metadata-repository.ts index 2662a9a4d6..67ad562e9f 100644 --- a/packages/workspace-server/src/db/repositories/task-metadata-repository.ts +++ b/packages/workspace-server/src/db/repositories/task-metadata-repository.ts @@ -14,6 +14,9 @@ export interface TaskMetadataPatch { lastViewedAt?: string | null; lastActivityAt?: string | null; archivedAt?: string | null; + archivedTitle?: string | null; + archivedTaskCreatedAt?: string | null; + archivedRepository?: string | null; piSessionFile?: string | null; } diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index 25a03fdc40..6168b2990c 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -59,6 +59,9 @@ export const taskMetadata = sqliteTable("task_metadata", { // row, so this timestamp is their only home — without it, archiving them is a // silent no-op and they reappear on the next refetch. archivedAt: text(), + archivedTitle: text(), + archivedTaskCreatedAt: text(), + archivedRepository: text(), piSessionFile: text(), createdAt: createdAt(), updatedAt: updatedAt(), @@ -102,6 +105,9 @@ export const archives = sqliteTable("archives", { .references(() => workspaces.id, { onDelete: "cascade" }), branchName: text(), checkpointId: text(), + title: text(), + taskCreatedAt: text(), + repository: text(), archivedAt: text().notNull(), createdAt: createdAt(), updatedAt: updatedAt(), diff --git a/packages/workspace-server/src/services/archive/archive-recovery.test.ts b/packages/workspace-server/src/services/archive/archive-recovery.test.ts new file mode 100644 index 0000000000..edc2c74e19 --- /dev/null +++ b/packages/workspace-server/src/services/archive/archive-recovery.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { recoverArchiveDetailsFromLogs } from "./archive-recovery"; + +const TASK_ID = "f7c8ae0f-0022-405e-8e36-da28c0f2c268"; +let tempDir: string | null = null; + +afterEach(async () => { + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +describe("recoverArchiveDetailsFromLogs", () => { + it("recovers archive details from the first prompt", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "archive-recovery-")); + const runDir = path.join(tempDir, "run-id"); + await fs.mkdir(runDir); + await fs.writeFile( + path.join(runDir, "logs.ndjson"), + [ + JSON.stringify({ + notification: { + method: "session/new", + params: { + cwd: "/repos/posthog-code", + _meta: { + systemPrompt: { append: `instructions\nTask-Id: ${TASK_ID}` }, + }, + }, + }, + }), + JSON.stringify({ + timestamp: "2026-07-22T19:00:00.000Z", + notification: { + method: "session/prompt", + params: { + prompt: [ + { type: "text", text: "Recover this task\nMore detail" }, + ], + }, + }, + }), + ].join("\n"), + ); + + await expect( + recoverArchiveDetailsFromLogs(new Set([TASK_ID]), tempDir), + ).resolves.toEqual([ + { + taskId: TASK_ID, + title: "Recover this task", + taskCreatedAt: "2026-07-22T19:00:00.000Z", + repository: "/repos/posthog-code", + }, + ]); + }); + + it("recovers repository identity when the prompt is missing", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "archive-recovery-")); + const runDir = path.join(tempDir, "run-id"); + await fs.mkdir(runDir); + await fs.writeFile( + path.join(runDir, "logs.ndjson"), + JSON.stringify({ + timestamp: "2026-07-22T19:00:00.000Z", + notification: { + method: "session/new", + params: { + cwd: "/repos/posthog-code", + _meta: { + systemPrompt: { append: `instructions\nTask-Id: ${TASK_ID}` }, + }, + }, + }, + }), + ); + + await expect( + recoverArchiveDetailsFromLogs(new Set([TASK_ID]), tempDir), + ).resolves.toEqual([ + { + taskId: TASK_ID, + title: `Unknown task (${TASK_ID.slice(0, 8)})`, + taskCreatedAt: "2026-07-22T19:00:00.000Z", + repository: "/repos/posthog-code", + }, + ]); + }); +}); diff --git a/packages/workspace-server/src/services/archive/archive-recovery.ts b/packages/workspace-server/src/services/archive/archive-recovery.ts new file mode 100644 index 0000000000..a402f81550 --- /dev/null +++ b/packages/workspace-server/src/services/archive/archive-recovery.ts @@ -0,0 +1,110 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; + +export interface RecoveredArchiveDetails { + taskId: string; + title: string; + taskCreatedAt: string | null; + repository: string | null; +} + +interface LogEntry { + timestamp?: string; + notification?: { + method?: string; + params?: { + cwd?: string; + prompt?: Array<{ type?: string; text?: string }>; + _meta?: { systemPrompt?: { append?: string } }; + }; + }; +} + +const TASK_ID_PATTERN = /Task-Id:\s*([0-9a-f-]{36})/i; + +export async function recoverArchiveDetailsFromLogs( + requestedTaskIds: ReadonlySet, + sessionsDir = path.join(os.homedir(), ".posthog-code", "sessions"), +): Promise { + let taskIds = requestedTaskIds; + if (taskIds.size === 0) return []; + const recovered: RecoveredArchiveDetails[] = []; + const directories = await fs.promises + .readdir(sessionsDir, { withFileTypes: true }) + .catch(() => []); + + for (const directory of directories) { + if (!directory.isDirectory()) continue; + const details = await recoverFromLog( + path.join(sessionsDir, directory.name, "logs.ndjson"), + taskIds, + ); + if (!details) continue; + recovered.push(details); + taskIds = new Set([...taskIds].filter((id) => id !== details.taskId)); + if (taskIds.size === 0) break; + } + return recovered; +} + +async function recoverFromLog( + logPath: string, + taskIds: ReadonlySet, +): Promise { + const stream = fs.createReadStream(logPath, { encoding: "utf8" }); + stream.on("error", () => {}); + const lines = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + let taskId: string | null = null; + let repository: string | null = null; + let sessionStartedAt: string | null = null; + + try { + for await (const line of lines) { + let entry: LogEntry; + try { + entry = JSON.parse(line) as LogEntry; + } catch { + continue; + } + const params = entry.notification?.params; + if (!taskId && entry.notification?.method === "session/new") { + const candidate = + params?._meta?.systemPrompt?.append?.match(TASK_ID_PATTERN)?.[1]; + if (!candidate || !taskIds.has(candidate)) return null; + taskId = candidate; + repository = params?.cwd ?? null; + sessionStartedAt = entry.timestamp ?? null; + } + if (taskId && entry.notification?.method === "session/prompt") { + const title = params?.prompt + ?.find((block) => block.type === "text") + ?.text?.trim(); + if (!title) continue; + return { + taskId, + title: title.split("\n")[0].slice(0, 200), + taskCreatedAt: entry.timestamp ?? null, + repository, + }; + } + } + } catch { + return null; + } finally { + lines.close(); + stream.destroy(); + } + return taskId + ? { + taskId, + title: `Unknown task (${taskId.slice(0, 8)})`, + taskCreatedAt: sessionStartedAt, + repository, + } + : null; +} diff --git a/packages/workspace-server/src/services/archive/archive.integration.test.ts b/packages/workspace-server/src/services/archive/archive.integration.test.ts index 20c129af08..c60da80c1e 100644 --- a/packages/workspace-server/src/services/archive/archive.integration.test.ts +++ b/packages/workspace-server/src/services/archive/archive.integration.test.ts @@ -3,7 +3,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { WorktreeManager } from "@posthog/git/worktree"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { recoverArchiveDetailsFromLogs } = vi.hoisted(() => ({ + recoverArchiveDetailsFromLogs: vi.fn(), +})); + +vi.mock("./archive-recovery", () => ({ recoverArchiveDetailsFromLogs })); vi.mock("electron", () => ({ app: { @@ -237,6 +243,101 @@ async function withTestContext( } describe("ArchiveService integration", () => { + beforeEach(() => { + recoverArchiveDetailsFromLogs.mockReset(); + recoverArchiveDetailsFromLogs.mockResolvedValue([]); + }); + + it("keeps task details after the cloud task is no longer available", () => + withTestContext({ mode: "local" }, async (ctx) => { + await ctx.service.archiveTask({ + taskId: TASK_ID, + title: "Recoverable task", + taskCreatedAt: "2026-07-23T10:00:00.000Z", + repository: "posthog/code", + }); + + expect(ctx.service.getArchivedTasks()[0]).toMatchObject({ + taskId: TASK_ID, + title: "Recoverable task", + taskCreatedAt: "2026-07-23T10:00:00.000Z", + repository: "posthog/code", + }); + })); + + it("keeps an archive identifiable when no original title can be recovered", () => + withTestContext({ mode: "local" }, async (ctx) => { + await ctx.service.archiveTask({ taskId: TASK_ID }); + + expect(ctx.service.getArchivedTasks()[0]).toMatchObject({ + taskId: TASK_ID, + title: `Unknown task (${TASK_ID.slice(0, 8)})`, + repository: ctx.repoPath, + }); + })); + + it("recovers legacy workspace details in the background and keeps the canonical repository", () => + withTestContext({ mode: "local", isArchived: true }, async (ctx) => { + recoverArchiveDetailsFromLogs.mockResolvedValue([ + { + taskId: TASK_ID, + title: "Recovered task", + taskCreatedAt: "2026-07-22T10:00:00.000Z", + repository: "/tmp/worktrees/code/task-1", + }, + ]); + + const initial = await ctx.service.listArchivedTasks(); + + expect(initial[0]).toMatchObject({ recoveryPending: true }); + await vi.waitFor(() => + expect(ctx.archiveRepo.findAll()[0]).toMatchObject({ + title: "Recovered task", + repository: ctx.repoPath, + }), + ); + await expect(ctx.service.listArchivedTasks()).resolves.toEqual([ + expect.objectContaining({ + recoveryPending: false, + repository: ctx.repoPath, + }), + ]); + })); + + it("persists an honest fallback when rowless details cannot be recovered", () => + withTestContext({ hasWorkspace: false }, async (ctx) => { + await ctx.service.archiveTask({ taskId: "rowless-task" }); + + expect(await ctx.service.listArchivedTasks()).toEqual([ + expect.objectContaining({ recoveryPending: true }), + ]); + await vi.waitFor(() => + expect(ctx.taskMetadataRepo.findByTaskId("rowless-task")).toMatchObject( + { + archivedTitle: "Unknown task (rowless-)", + }, + ), + ); + await expect(ctx.service.listArchivedTasks()).resolves.toEqual([ + expect.objectContaining({ recoveryPending: false }), + ]); + })); + + it("restarts recovery after a later incomplete archive", () => + withTestContext({ mode: "local", isArchived: true }, async (ctx) => { + await ctx.service.listArchivedTasks(); + await vi.waitFor(() => + expect(recoverArchiveDetailsFromLogs).toHaveBeenCalledTimes(1), + ); + + await ctx.service.archiveTask({ taskId: "rowless-task" }); + await ctx.service.listArchivedTasks(); + + await vi.waitFor(() => + expect(recoverArchiveDetailsFromLogs).toHaveBeenCalledTimes(2), + ); + })); + describe("worktree mode", () => { it("archive and unarchive preserves uncommitted changes", () => withTestContext({}, async (ctx) => { diff --git a/packages/workspace-server/src/services/archive/archive.ts b/packages/workspace-server/src/services/archive/archive.ts index e7ab427c62..794249b0dc 100644 --- a/packages/workspace-server/src/services/archive/archive.ts +++ b/packages/workspace-server/src/services/archive/archive.ts @@ -52,6 +52,7 @@ import { } from "../worktree-checkpoint/worktree-checkpoint"; import { deriveWorktreePath as deriveWorktreePathFromBase } from "../worktree-path/worktree-path"; import { getCurrentBranchName } from "../worktree-query/worktree-query"; +import { recoverArchiveDetailsFromLogs } from "./archive-recovery"; import { ARCHIVE_FILE_WATCHER, ARCHIVE_SESSION_CANCELLER } from "./identifiers"; import type { ArchiveFileWatcher, SessionCanceller } from "./ports"; import type { ArchivedTask, ArchiveTaskInput } from "./schemas"; @@ -90,6 +91,7 @@ export class ArchiveService { } private readonly log: ScopedLogger; + private recoveryStarted = false; async archiveTask(input: ArchiveTaskInput): Promise { this.log.info(`Archiving task ${input.taskId}`); @@ -105,6 +107,9 @@ export class ArchiveService { try { const result = await this.executeArchive(input, runWithRollback); + if (!input.title) { + this.recoveryStarted = false; + } this.log.info(`Task ${input.taskId} archived successfully`); return result; } catch (error) { @@ -137,7 +142,12 @@ export class ArchiveService { const archivedAt = new Date().toISOString(); await step( async () => { - this.taskMetadataRepo.upsert(taskId, { archivedAt }); + this.taskMetadataRepo.upsert(taskId, { + archivedAt, + archivedTitle: input.title ?? null, + archivedTaskCreatedAt: input.taskCreatedAt ?? null, + archivedRepository: input.repository ?? null, + }); }, async () => { this.taskMetadataRepo.upsert(taskId, { archivedAt: null }); @@ -151,6 +161,9 @@ export class ArchiveService { worktreeName: null, branchName: null, checkpointId: null, + title: input.title ?? null, + taskCreatedAt: input.taskCreatedAt ?? null, + repository: input.repository ?? null, }; } @@ -179,6 +192,9 @@ export class ArchiveService { workspaceId: workspace.id, branchName: archivedTask.branchName, checkpointId: archivedTask.checkpointId, + title: input.title ?? null, + taskCreatedAt: input.taskCreatedAt ?? null, + repository: input.repository ?? null, }); }, async () => { @@ -339,6 +355,9 @@ export class ArchiveService { workspaceId: workspace.id, branchName: archivedTask.branchName, checkpointId: archivedTask.checkpointId, + title: input.title ?? null, + taskCreatedAt: input.taskCreatedAt ?? null, + repository: input.repository ?? null, }); }, async () => { @@ -479,6 +498,9 @@ export class ArchiveService { workspaceId: workspace.id, branchName: archive.branchName, checkpointId: archive.checkpointId, + title: archive.title, + taskCreatedAt: archive.taskCreatedAt, + repository: archive.repository, }); }, ); @@ -492,7 +514,12 @@ export class ArchiveService { archive.workspaceId, ) as Workspace; const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - return this.toArchivedTask(workspace, archive, worktree?.name ?? null); + return this.toArchivedTask( + workspace, + archive, + worktree?.name ?? null, + worktree?.path ?? null, + ); }); const rowless = this.rowlessArchived().map( (meta): ArchivedTask => ({ @@ -504,11 +531,83 @@ export class ArchiveService { worktreeName: null, branchName: null, checkpointId: null, + title: + meta.archivedTitle ?? `Unknown task (${meta.taskId.slice(0, 8)})`, + taskCreatedAt: meta.archivedTaskCreatedAt ?? meta.createdAt, + repository: meta.archivedRepository, + recoveryPending: !meta.archivedTitle, }), ); return [...fromWorkspaces, ...rowless]; } + async listArchivedTasks(): Promise { + if (!this.recoveryStarted) { + this.recoveryStarted = true; + void this.recoverArchivedTaskDetails().catch((error) => { + this.recoveryStarted = false; + this.log.warn("Failed to recover archived task details", { error }); + }); + } + return this.getArchivedTasks(); + } + + private async recoverArchivedTaskDetails(): Promise { + const missing = this.archiveRepo + .findAll() + .filter((archive) => !archive.title) + .map((archive) => ({ + archive, + workspace: this.workspaceRepo.findById(archive.workspaceId), + })) + .filter( + (item): item is { archive: Archive; workspace: Workspace } => + item.workspace !== null, + ); + const rowlessMissing = this.rowlessArchived().filter( + (metadata) => !metadata.archivedTitle, + ); + const recovered = await recoverArchiveDetailsFromLogs( + new Set([ + ...missing.map(({ workspace }) => workspace.taskId), + ...rowlessMissing.map((metadata) => metadata.taskId), + ]), + ); + const byTaskId = new Map( + recovered.map((details) => [details.taskId, details]), + ); + for (const { archive, workspace } of missing) { + const details = byTaskId.get(workspace.taskId); + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + const repository = workspace.repositoryId + ? this.repositoryRepo.findById(workspace.repositoryId) + : null; + const recoveredTitle = details?.title; + const title = + recoveredTitle && !recoveredTitle.startsWith("Unknown task (") + ? recoveredTitle + : this.unknownTaskTitle( + workspace.taskId, + archive.branchName, + worktree?.name ?? null, + ); + this.archiveRepo.updateDetailsByWorkspaceId(archive.workspaceId, { + title, + taskCreatedAt: details?.taskCreatedAt ?? workspace.createdAt, + repository: repository?.path ?? details?.repository ?? worktree?.path, + }); + } + for (const metadata of rowlessMissing) { + const details = byTaskId.get(metadata.taskId); + this.taskMetadataRepo.upsert(metadata.taskId, { + archivedTitle: + details?.title ?? this.unknownTaskTitle(metadata.taskId, null, null), + archivedTaskCreatedAt: details?.taskCreatedAt ?? metadata.createdAt, + archivedRepository: details?.repository ?? metadata.archivedRepository, + }); + } + } + // Tasks archived via `task_metadata` (no `workspaces` row). A task that has a // workspace row is owned by the `archives` table, so it's excluded here even // if an `archivedAt` lingers in its metadata — otherwise it would surface @@ -590,7 +689,12 @@ export class ArchiveService { workspace: Workspace, archive: Archive, worktreeName: string | null, + worktreePath: string | null, ): ArchivedTask { + const repository = + !archive.repository && workspace.repositoryId + ? this.repositoryRepo.findById(workspace.repositoryId) + : null; return { taskId: workspace.taskId, archivedAt: archive.archivedAt, @@ -599,9 +703,27 @@ export class ArchiveService { worktreeName, branchName: archive.branchName, checkpointId: archive.checkpointId, + title: + archive.title ?? + this.unknownTaskTitle( + workspace.taskId, + archive.branchName, + worktreeName, + ), + taskCreatedAt: archive.taskCreatedAt ?? workspace.createdAt, + repository: archive.repository ?? repository?.path ?? worktreePath, + recoveryPending: !archive.title, }; } + private unknownTaskTitle( + taskId: string, + branchName: string | null, + worktreeName: string | null, + ): string { + return `Unknown task (${branchName ?? worktreeName ?? taskId.slice(0, 8)})`; + } + private deriveWorktreePath(folderPath: string, worktreeName: string): string { return deriveWorktreePathFromBase( this.workspaceSettings.getWorktreeLocation(), diff --git a/packages/workspace-server/src/services/archive/schemas.ts b/packages/workspace-server/src/services/archive/schemas.ts index b7447c9ef6..a16178829f 100644 --- a/packages/workspace-server/src/services/archive/schemas.ts +++ b/packages/workspace-server/src/services/archive/schemas.ts @@ -8,12 +8,19 @@ export const archivedTaskSchema = z.object({ worktreeName: z.string().nullable(), branchName: z.string().nullable(), checkpointId: z.string().nullable(), + title: z.string().nullable().optional(), + taskCreatedAt: z.string().nullable().optional(), + repository: z.string().nullable().optional(), + recoveryPending: z.boolean().optional(), }); export type ArchivedTask = z.infer; export const archiveTaskInput = z.object({ taskId: z.string(), + title: z.string().optional(), + taskCreatedAt: z.string().optional(), + repository: z.string().nullable().optional(), }); export type ArchiveTaskInput = z.infer; From 51a9d10d981e360cf86107201d20408cb9e055a3 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Thu, 23 Jul 2026 15:21:42 +0300 Subject: [PATCH 2/3] fix(archive): address review feedback Read archive metadata from summary caches and restore task navigation. Generated-By: PostHog Code Task-Id: 68800b78-554e-42ce-9077-b5125e27a6aa --- .../features/archive/ArchivedTasksView.tsx | 17 ++++++------ .../features/archive/useArchiveTask.test.ts | 22 +++++++++++++++ .../ui/src/features/archive/useArchiveTask.ts | 27 ++++++++++++++----- 3 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 packages/ui/src/features/archive/useArchiveTask.test.ts diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx index 9e91c3a622..2eb44e0828 100644 --- a/packages/ui/src/features/archive/ArchivedTasksView.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -20,6 +20,7 @@ import { } from "@posthog/core/archive/archiveListView"; import { useHostTRPC } from "@posthog/host-router/react"; import type { WorkspaceMode } from "@posthog/shared"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { openTask } from "@posthog/ui/router/useOpenTask"; import { AlertDialog, @@ -32,7 +33,7 @@ import { Text, TextField, } from "@radix-ui/themes"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMemo, useRef, useState } from "react"; import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; @@ -484,6 +485,7 @@ export function ArchivedTasksViewPresentation({ export function ArchivedTasksView() { const trpc = useHostTRPC(); + const queryClient = useQueryClient(); const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery({ ...trpc.archive.list.queryOptions(), refetchInterval: (query) => @@ -518,16 +520,15 @@ export function ArchivedTasksView() { const applyRestoreOutcome = (taskId: string, outcome: RestoreOutcome) => { if (outcome.kind === "restored") { - const task = - outcome.navigateToTaskId === null - ? null - : (listedTasks.find((item) => item.id === outcome.navigateToTaskId) ?? - null); + const navigateToTaskId = outcome.navigateToTaskId; toast.success("Task unarchived", { - action: task + action: navigateToTaskId ? { label: "View task", - onClick: () => void openTask(task), + onClick: () => + void queryClient + .fetchQuery(taskDetailQuery(navigateToTaskId)) + .then(openTask), } : undefined, }); diff --git a/packages/ui/src/features/archive/useArchiveTask.test.ts b/packages/ui/src/features/archive/useArchiveTask.test.ts new file mode 100644 index 0000000000..f01e7b4e54 --- /dev/null +++ b/packages/ui/src/features/archive/useArchiveTask.test.ts @@ -0,0 +1,22 @@ +import type { Schemas } from "@posthog/api-client"; +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { taskKeys } from "../tasks/taskKeys"; +import { getCachedArchiveTask } from "./useArchiveTask"; + +describe("getCachedArchiveTask", () => { + it("reads metadata from a task summary when no full list is cached", () => { + const queryClient = new QueryClient(); + const summary = { + id: "task-1", + title: "Archived from sidebar", + repository: "posthog/code", + created_at: "2026-07-23T10:00:00.000Z", + updated_at: "2026-07-23T11:00:00.000Z", + latest_run: null, + } satisfies Schemas.TaskSummary; + queryClient.setQueryData(taskKeys.summaries([summary.id]), [summary]); + + expect(getCachedArchiveTask(queryClient, summary.id)).toEqual(summary); + }); +}); diff --git a/packages/ui/src/features/archive/useArchiveTask.ts b/packages/ui/src/features/archive/useArchiveTask.ts index 7934157f3b..d89ce573f2 100644 --- a/packages/ui/src/features/archive/useArchiveTask.ts +++ b/packages/ui/src/features/archive/useArchiveTask.ts @@ -1,3 +1,4 @@ +import type { Schemas } from "@posthog/api-client"; import { type ArchiveCacheWriter, type ArchiveOrchestrationDeps, @@ -21,6 +22,7 @@ import { useReviewViewedStore } from "@posthog/ui/features/code-review/reviewVie import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFocusStore } from "@posthog/ui/features/focus/focusStore"; import { pinnedTasksApi } from "@posthog/ui/features/sidebar/taskMetaApi"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; import { destroyTaskTerminals } from "@posthog/ui/features/terminal/destroyTaskTerminals"; import { toast } from "@posthog/ui/primitives/toast"; import { getAppViewSnapshot } from "@posthog/ui/router/useAppView"; @@ -70,6 +72,24 @@ function makeCacheWriter( }; } +export function getCachedArchiveTask( + queryClient: QueryClient, + taskId: string, +): Pick | undefined { + return ( + queryClient + .getQueriesData({ queryKey: taskKeys.lists() }) + .flatMap(([, tasks]) => tasks ?? []) + .find((item) => item.id === taskId) ?? + queryClient + .getQueriesData({ + queryKey: taskKeys.allSummaries(), + }) + .flatMap(([, tasks]) => tasks ?? []) + .find((item) => item.id === taskId) + ); +} + function makeOrchestrationDeps( queryClient: QueryClient, keys: ArchiveCacheKeys, @@ -128,12 +148,7 @@ function makeOrchestrationDeps( taskId, ), archive: (taskId) => { - const task = queryClient - .getQueriesData({ - queryKey: ["tasks", "list"], - }) - .flatMap(([, tasks]) => tasks ?? []) - .find((item) => item.id === taskId); + const task = getCachedArchiveTask(queryClient, taskId); return hostClient.archive.archive .mutate({ taskId, From b4f36479bde4e46d6df6a30299605b54fabceca0 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Thu, 23 Jul 2026 15:24:59 +0300 Subject: [PATCH 3/3] fix(archive): preserve partial archive metadata Keep each recovered archive field independent when another is unavailable. Generated-By: PostHog Code Task-Id: 68800b78-554e-42ce-9077-b5125e27a6aa --- .../core/src/archive/archiveListView.test.ts | 46 +++++++++++++++++++ packages/core/src/archive/archiveListView.ts | 20 ++++---- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/core/src/archive/archiveListView.test.ts b/packages/core/src/archive/archiveListView.test.ts index 4ad23175e5..de475de52c 100644 --- a/packages/core/src/archive/archiveListView.test.ts +++ b/packages/core/src/archive/archiveListView.test.ts @@ -68,6 +68,52 @@ describe("mergeArchivedWithTasks", () => { { archived, task }, ]); }); + + it.each([ + { + name: "creation date", + patch: { taskCreatedAt: undefined }, + expected: { + title: "Recovered title", + created_at: null, + repository: "posthog/code", + }, + }, + { + name: "title", + patch: { title: undefined }, + expected: { + title: "Unknown task (older-ta)", + created_at: "2024-01-01T00:00:00.000Z", + repository: "posthog/code", + }, + }, + { + name: "repository", + patch: { repository: undefined }, + expected: { + title: "Recovered title", + created_at: "2024-01-01T00:00:00.000Z", + repository: null, + }, + }, + ])( + "preserves other archived metadata when $name is missing", + ({ patch, expected }) => { + const archived = { + ...makeArchived("older-task", "2024-01-02T00:00:00.000Z"), + title: "Recovered title", + taskCreatedAt: "2024-01-01T00:00:00.000Z", + repository: "posthog/code", + ...patch, + }; + + expect(mergeArchivedWithTasks([archived], [])[0].task).toEqual({ + id: "older-task", + ...expected, + }); + }, + ); }); describe("filterAndSortArchivedTasks", () => { diff --git a/packages/core/src/archive/archiveListView.ts b/packages/core/src/archive/archiveListView.ts index 6a785c39ee..e44f52c10c 100644 --- a/packages/core/src/archive/archiveListView.ts +++ b/packages/core/src/archive/archiveListView.ts @@ -2,10 +2,10 @@ import type { ArchivedTask } from "@posthog/shared"; import { formatRelativeTimeLong } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; -export type ArchivedTaskDetails = Pick< - Task, - "id" | "title" | "created_at" | "repository" ->; +export interface ArchivedTaskDetails + extends Pick { + created_at: Task["created_at"] | null; +} export interface ArchivedTaskWithDetails { archived: ArchivedTask; @@ -39,18 +39,20 @@ export function mergeArchivedWithTasks( archived, task: taskMap.get(archived.taskId) ?? - (archived.title && archived.taskCreatedAt + (archived.title || archived.taskCreatedAt || archived.repository ? { id: archived.taskId, - title: archived.title, - created_at: archived.taskCreatedAt, - repository: archived.repository, + title: + archived.title ?? + `Unknown task (${archived.branchName ?? archived.worktreeName ?? archived.taskId.slice(0, 8)})`, + created_at: archived.taskCreatedAt ?? null, + repository: archived.repository ?? null, } : null), })); } -export function formatRelativeDate(isoDate: string | undefined): string { +export function formatRelativeDate(isoDate: string | null | undefined): string { if (!isoDate) return "—"; return formatRelativeTimeLong(isoDate); }