feat: add restore/unarchive for archived sessions
Archived sessions had no way back to the active list: the only available action was "Delete permanently". Add restore per session (sidebar context menu, Archive page row) and in bulk (sidebar selection bar). The OpenCode server cannot clear time.archived over HTTP — session.update only applies the field for a finite number, so an omitted key is a no-op and null is silently ignored (verified against opencode 1.18.12). Restore therefore writes time.archived = 0: every client-side reader classifies archive state by truthiness, so 0 reads as active in the UI, the event reducer, and the OpenCode app/TUI. The server's time_archived IS NULL list filter still excludes such rows, so the global session cache no longer issues an archived:false request for its active list. Full and per-directory loads now fetch once with the inclusive flag and split client-side via splitGlobalSessionsByArchived, which also halves per-directory refresh requests. Directory bootstrap keeps the server filter because live child stores must not hold archived sessions; a restored session re-enters its live store through the authoritative session.updated event. unarchiveSession/unarchiveSessions follow the archiveSession contract: wait for server confirmation before reconciling stores, runtime-guard every reconciliation, preserve partial batch results, and fail loudly when the server keeps the session archived instead of toasting a successful no-op. Closes #2346
This commit is contained in:
@@ -59,8 +59,8 @@ User-visible session ordering is also not owned by the global cache array order.
|
||||
|
||||
Global refresh rules:
|
||||
|
||||
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
|
||||
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
|
||||
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. The global cache therefore loads with one inclusive request (`archived: true`) and splits active/archived client-side via `splitGlobalSessionsByArchived` — an `archived: false` request cannot be truthful because the server filter excludes restored sessions (`time.archived` falsy-but-present, see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`). For callers that still want only archived records, `listGlobalSessionPages` narrows inclusive responses at the data boundary (default `narrowToArchived`), so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
|
||||
- Per-directory refresh issues one inclusive request per directory (previously two), bounded to two requests across callers and prioritizing the current directory.
|
||||
- Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally.
|
||||
- Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions.
|
||||
- Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
|
||||
|
||||
import { listGlobalSessionPages } from './globalSessions'
|
||||
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
|
||||
|
||||
describe('listGlobalSessionPages', () => {
|
||||
test('sanitizes session list records before returning them', async () => {
|
||||
@@ -138,6 +138,27 @@ describe('listGlobalSessionPages', () => {
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2'])
|
||||
})
|
||||
|
||||
test('returns the inclusive response unfiltered when narrowing is disabled', async () => {
|
||||
const apiClient = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: 'ses_active', time: { created: 1, updated: 20 } },
|
||||
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
|
||||
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
|
||||
],
|
||||
response: { headers: new Headers() },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const sessions = await listGlobalSessionPages(apiClient, { archived: true, narrowToArchived: false, pageSize: 500 })
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_active', 'ses_archived', 'ses_restored'])
|
||||
})
|
||||
|
||||
test('keeps paginating archived pages that are full of non-archived records', async () => {
|
||||
const calls: Array<Record<string, unknown>> = []
|
||||
const apiClient = {
|
||||
@@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => {
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitGlobalSessionsByArchived', () => {
|
||||
test('classifies restored (falsy archived) records as active', () => {
|
||||
const { active, archived } = splitGlobalSessionsByArchived([
|
||||
{ id: 'ses_active', time: { created: 1, updated: 20 } },
|
||||
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
|
||||
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
|
||||
] as unknown as Parameters<typeof splitGlobalSessionsByArchived>[0])
|
||||
|
||||
expect(active.map((session) => session.id)).toEqual(['ses_active', 'ses_restored'])
|
||||
expect(archived.map((session) => session.id)).toEqual(['ses_archived'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,11 +84,38 @@ const unwrapSessionList = (
|
||||
*/
|
||||
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived);
|
||||
|
||||
/**
|
||||
* Split an inclusive (`archived: true`) session page stream into active and
|
||||
* archived buckets. Restored sessions carry `time.archived === 0` (see
|
||||
* `UNARCHIVED_TIMESTAMP` in `sync/session-actions.ts`); the truthiness check
|
||||
* classifies them as active even though the server's own
|
||||
* `time_archived IS NULL` filter would still exclude them, which is why the
|
||||
* global cache must split client-side instead of issuing an
|
||||
* `archived: false` request for its active list.
|
||||
*/
|
||||
export const splitGlobalSessionsByArchived = <T extends GlobalSessionRecord>(
|
||||
sessions: T[],
|
||||
): { active: T[]; archived: T[] } => {
|
||||
const active: T[] = [];
|
||||
const archived: T[] = [];
|
||||
for (const session of sessions) {
|
||||
if (isArchivedSession(session)) archived.push(session);
|
||||
else active.push(session);
|
||||
}
|
||||
return { active, archived };
|
||||
};
|
||||
|
||||
export async function listGlobalSessionPages(
|
||||
apiClient: OpencodeClient,
|
||||
options: {
|
||||
directory?: string;
|
||||
archived: boolean;
|
||||
/**
|
||||
* When `archived` is true, narrow results to records carrying a truthy
|
||||
* `time.archived` (default true). Pass false to receive the inclusive
|
||||
* server response unfiltered, e.g. to split active/archived locally.
|
||||
*/
|
||||
narrowToArchived?: boolean;
|
||||
roots?: boolean;
|
||||
pageSize: number;
|
||||
onPage?: (sessions: GlobalSessionRecord[]) => void;
|
||||
@@ -97,17 +124,17 @@ export async function listGlobalSessionPages(
|
||||
const all: GlobalSessionRecord[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let cursor: number | undefined;
|
||||
const narrowToArchived = options.narrowToArchived !== false;
|
||||
let operation: string;
|
||||
if (!options.directory) {
|
||||
operation = `global-sessions.${options.archived ? "archived" : "active"}`;
|
||||
operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
|
||||
} else if (options.roots === true) {
|
||||
operation = "bootstrap.sessions.roots";
|
||||
} else if (options.archived) {
|
||||
operation = "bootstrap.sessions.archived";
|
||||
operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
|
||||
} else {
|
||||
operation = "bootstrap.sessions.all";
|
||||
}
|
||||
|
||||
while (true) {
|
||||
let attempts = 0;
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
@@ -150,7 +177,7 @@ export async function listGlobalSessionPages(
|
||||
if (!session?.id || seenIds.has(session.id)) continue;
|
||||
seenIds.add(session.id);
|
||||
appended += 1;
|
||||
if (options.archived && !isArchivedSession(session)) continue;
|
||||
if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
|
||||
all.push(session);
|
||||
accepted.push(session);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let activeRequest: Deferred<Session[]>
|
||||
let archivedRequest: Deferred<Session[]>
|
||||
let listRequest: Deferred<Session[]>
|
||||
|
||||
// The store issues one inclusive (`archived: true`) paginated request per
|
||||
// load/refresh scope and splits active/archived client-side, so restored
|
||||
// sessions (`time.archived` falsy-but-present) stay visible in the active
|
||||
// list. The mock serves that single request.
|
||||
const sdk = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async (options: { archived?: boolean }) => ({
|
||||
data: await (options.archived ? archivedRequest.promise : activeRequest.promise),
|
||||
list: async () => ({
|
||||
data: await listRequest.promise,
|
||||
response: { headers: new Headers() },
|
||||
}),
|
||||
},
|
||||
@@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient
|
||||
const session = (id: string, title = id, archived?: number): Session => ({
|
||||
id,
|
||||
title,
|
||||
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) },
|
||||
time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
|
||||
} as Session)
|
||||
|
||||
describe("global session mutation reconciliation", () => {
|
||||
beforeEach(() => {
|
||||
activeRequest = deferred<Session[]>()
|
||||
archivedRequest = deferred<Session[]>()
|
||||
listRequest = deferred<Session[]>()
|
||||
opencodeClient.getSdkClient = () => sdk
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
|
||||
})
|
||||
@@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("created"))
|
||||
|
||||
activeRequest.resolve([])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
|
||||
@@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().removeSessions([stale.id])
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([stale])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
|
||||
@@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([stale])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
|
||||
@@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([stale])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New")
|
||||
})
|
||||
|
||||
test("uses commit-time state when one side of the load fails", async () => {
|
||||
test("uses commit-time state when the load fails", async () => {
|
||||
const created = session("created")
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(created)
|
||||
|
||||
activeRequest.reject(new Error("unavailable"))
|
||||
archivedRequest.resolve([])
|
||||
listRequest.reject(new Error("unavailable"))
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
|
||||
expect(useGlobalSessionsStore.getState().status).toBe("error")
|
||||
})
|
||||
|
||||
test("splits a restored session into the active list", async () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
|
||||
listRequest.resolve([session("active"), session("archived", "archived", 5), session("restored", "restored", 0)])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["active", "restored"])
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions.map((item) => item.id)).toEqual(["archived"])
|
||||
expect(useGlobalSessionsStore.getState().status).toBe("ready")
|
||||
})
|
||||
|
||||
test("does not undo a move while refreshing the source directory", async () => {
|
||||
const source = { ...session("moved"), directory: "/source" } as Session
|
||||
const destination = { ...source, directory: "/destination" } as Session
|
||||
@@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => {
|
||||
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
|
||||
useGlobalSessionsStore.getState().upsertSession(destination)
|
||||
|
||||
activeRequest.resolve([source])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([source])
|
||||
await refreshing
|
||||
|
||||
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
|
||||
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved")
|
||||
})
|
||||
|
||||
test("keeps a restore mutation newer than the directory refresh", async () => {
|
||||
const archived = { ...session("restored", "restored", 5), directory: "/source" } as Session
|
||||
useGlobalSessionsStore.getState().applySnapshot([], [archived])
|
||||
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
|
||||
useGlobalSessionsStore.getState().upsertSession({ ...archived, time: { ...archived.time, archived: 0 } })
|
||||
|
||||
// The server still reports the pre-restore row for this directory.
|
||||
listRequest.resolve([archived])
|
||||
await refreshing
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["restored"])
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { listGlobalSessionPages } from '@/stores/globalSessions';
|
||||
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
|
||||
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
@@ -253,7 +253,6 @@ type DirectoryPageResult = {
|
||||
const fetchDirectoryPages = async (
|
||||
sdk: OpencodeClient,
|
||||
directories: Set<string>,
|
||||
archived: boolean,
|
||||
): Promise<DirectoryPageResult> => {
|
||||
const currentDirectory = normalizePath(opencodeClient.getDirectory());
|
||||
const orderedDirectories = [...directories].sort((left, right) => {
|
||||
@@ -267,8 +266,11 @@ const fetchDirectoryPages = async (
|
||||
status: 'fulfilled' as const,
|
||||
value: {
|
||||
directory,
|
||||
// One inclusive request per directory: the server has no filter that
|
||||
// returns only active sessions including restored (`time.archived`
|
||||
// falsy-but-present) rows, so fetch everything and split client-side.
|
||||
sessions: await withDirectorySessionRefreshSlot(() => (
|
||||
listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE })
|
||||
listGlobalSessionPages(sdk, { directory, archived: true, narrowToArchived: false, pageSize: PAGE_SIZE })
|
||||
)),
|
||||
},
|
||||
};
|
||||
@@ -526,35 +528,25 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [activeResult, archivedResult] = await Promise.allSettled([
|
||||
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }),
|
||||
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
|
||||
]);
|
||||
|
||||
if (activeResult.status === 'rejected') {
|
||||
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
|
||||
}
|
||||
if (archivedResult.status === 'rejected') {
|
||||
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
|
||||
}
|
||||
// One inclusive fetch, split client-side. The server's
|
||||
// `time_archived IS NULL` active filter would exclude restored
|
||||
// sessions (`time.archived` falsy-but-present), so an
|
||||
// `archived: false` request cannot produce a truthful active list.
|
||||
const allSessions = await listGlobalSessionPages(sdk, {
|
||||
archived: true,
|
||||
narrowToArchived: false,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (generation !== loadGeneration) {
|
||||
// Runtime switched mid-load: this snapshot belongs to the previous
|
||||
// instance — drop it.
|
||||
return { activeSessions: [], archivedSessions: [] };
|
||||
}
|
||||
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled'
|
||||
? 'ready'
|
||||
: 'error';
|
||||
const { active, archived } = splitGlobalSessionsByArchived(allSessions);
|
||||
set((state) => {
|
||||
const fetchedActive = activeResult.status === 'fulfilled'
|
||||
? activeResult.value
|
||||
: mergeSessionLists(state.activeSessions, fallbackActive);
|
||||
const fetchedArchived = archivedResult.status === 'fulfilled'
|
||||
? archivedResult.value
|
||||
: state.archivedSessions;
|
||||
const reconciled = overlayMutationsSince(state, fetchedActive, fetchedArchived, baselineRevision);
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, status);
|
||||
const reconciled = overlayMutationsSince(state, active, archived, baselineRevision);
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
|
||||
});
|
||||
const committed = get();
|
||||
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
|
||||
@@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
const generation = loadGeneration;
|
||||
const baselineRevision = get().mutationRevision;
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [active, archived] = await Promise.all([
|
||||
fetchDirectoryPages(sdk, directorySet, false),
|
||||
fetchDirectoryPages(sdk, directorySet, true),
|
||||
]);
|
||||
const fetched = await fetchDirectoryPages(sdk, directorySet);
|
||||
|
||||
if (generation !== loadGeneration) {
|
||||
const state = get();
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
}
|
||||
|
||||
if (active.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
|
||||
}
|
||||
if (archived.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
|
||||
if (fetched.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]);
|
||||
}
|
||||
|
||||
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
|
||||
|
||||
set((state) => {
|
||||
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories);
|
||||
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
|
||||
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
|
||||
if (sameSessionList(state.activeSessions, nextActiveSessions)) {
|
||||
nextActiveSessions = state.activeSessions;
|
||||
}
|
||||
|
||||
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived.sessions, archived.directories);
|
||||
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived, fetched.directories);
|
||||
if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
|
||||
nextArchivedSessions = state.archivedSessions;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user