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:
@@ -240,16 +240,38 @@ Examples of global-store updates performed in `session-actions.ts`:
|
||||
- `updateSessionTitle()` -> `upsertSession(result.data)`
|
||||
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
|
||||
- `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session
|
||||
- `unarchiveSession()` / `unarchiveSessions()` -> wait for server confirmation, then upsert each restored session
|
||||
- `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state
|
||||
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
|
||||
|
||||
### Restore (unarchive) contract
|
||||
|
||||
The OpenCode server cannot clear `time.archived` over HTTP: `session.update`
|
||||
only applies the field when the payload carries a finite number, so an omitted
|
||||
key is a no-op and `null` is silently ignored. Restore therefore writes
|
||||
`time.archived = 0` (`UNARCHIVED_TIMESTAMP` in `session-actions.ts`). Every
|
||||
client-side reader classifies archive state by truthiness of `time.archived`,
|
||||
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
|
||||
any query that wants a truthful active list must fetch inclusively
|
||||
(`archived: true`) and split client-side (`splitGlobalSessionsByArchived`).
|
||||
The global sessions store does this for its full and per-directory loads;
|
||||
directory bootstrap keeps using the server filter because live child stores
|
||||
must not hold archived sessions. A restored session re-enters its live
|
||||
directory store through the authoritative `session.updated` event the server
|
||||
publishes for the update; until then it remains fully visible through the
|
||||
global store (sidebar, switcher) and addressable by ID (message loading).
|
||||
|
||||
Archive and delete actions capture the active runtime key when they start and
|
||||
recheck it before every store reconciliation, so a response
|
||||
produced by the previous runtime is rejected instead of mutating the current
|
||||
runtime's live or global session state. A guarded batch stops at the first
|
||||
observed runtime change: sessions the server already confirmed remain archived
|
||||
or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed
|
||||
on the captured runtime is returned in `failedIds` so existing partial-failure
|
||||
runtime's live or global session state. Restore follows the same guard: a
|
||||
stale completion returns `false` without touching any store. A guarded batch
|
||||
stops at the first observed runtime change: sessions the server already
|
||||
confirmed remain archived, restored, or deleted and stay in
|
||||
`archivedIds`/`restoredIds`/`deletedIds`, while every ID not confirmed on the
|
||||
captured runtime is returned in `failedIds` so existing partial-failure
|
||||
feedback stays truthful.
|
||||
Callers whose confirmation can span a runtime switch may pass an
|
||||
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default.
|
||||
|
||||
@@ -619,6 +619,116 @@ describe("confirmed session removal", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session restore (unarchive)", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
registeredSessionDirectories.length = 0
|
||||
globalUpsertedSessions.length = 0
|
||||
sessionUpdateResult = {}
|
||||
beforeSessionUpdateResolve = null
|
||||
})
|
||||
|
||||
test("does not restore locally until the server returns the restored session", async () => {
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
expect(await unarchiveSession("session-a")).toBe(false)
|
||||
expect(globalUpsertedSessions).toEqual([])
|
||||
expect(registeredSessionDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("sends the archive-clearing sentinel and upserts the restored session after confirmation", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
expect(await unarchiveSession("session-a")).toBe(true)
|
||||
// The server cannot clear time.archived over HTTP, so the action must
|
||||
// write the falsy sentinel rather than omitting the field.
|
||||
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([{
|
||||
method: "session.update",
|
||||
params: { sessionID: "session-a", time: { archived: 0 }, directory: "/test/project" },
|
||||
}])
|
||||
expect((globalUpsertedSessions[0] as Session)?.time?.archived).toBe(0)
|
||||
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/test/project" }])
|
||||
})
|
||||
|
||||
test("fails when the server keeps the session archived", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
// A silent server-side no-op must surface as a failure, not a success toast.
|
||||
expect(await unarchiveSession("session-a")).toBe(false)
|
||||
expect(globalUpsertedSessions).toEqual([])
|
||||
expect(registeredSessionDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("rejects a restore response that arrives after a runtime switch", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-a.test", runtimeKey: "restore-runtime-a" })
|
||||
beforeSessionUpdateResolve = () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-b.test", runtimeKey: "restore-runtime-b" })
|
||||
}
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
expect(await unarchiveSession("session-a")).toBe(false)
|
||||
expect(getRuntimeKey()).toBe("restore-runtime-b")
|
||||
// The stale response must not reconcile the runtime the user switched to.
|
||||
expect(globalUpsertedSessions).toEqual([])
|
||||
expect(registeredSessionDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps confirmed sessions and fails the rest when the runtime changes mid-batch", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-a.test", runtimeKey: "restore-batch-a" })
|
||||
beforeSessionUpdateResolve = (sessionId) => {
|
||||
if (sessionId === "session-b") {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-b.test", runtimeKey: "restore-batch-b" })
|
||||
}
|
||||
}
|
||||
const { unarchiveSessions, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
const result = await unarchiveSessions(["session-a", "session-b", "session-c"])
|
||||
|
||||
// session-a was confirmed before the switch and stays restored; session-b's
|
||||
// response is stale and session-c is never attempted, so both are reported
|
||||
// as failures instead of being silently dropped.
|
||||
expect(result).toEqual({ restoredIds: ["session-a"], failedIds: ["session-b", "session-c"] })
|
||||
expect(globalUpsertedSessions).toHaveLength(1)
|
||||
// session-c must not reach the SDK after the runtime changed.
|
||||
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
||||
.toEqual(["session-a", "session-b"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchMessagesForSession startup race", () => {
|
||||
test("does not reject before sync action refs are initialized", async () => {
|
||||
const { fetchMessagesForSession } = await import("./session-actions")
|
||||
|
||||
@@ -1014,6 +1014,92 @@ export async function archiveSessions(
|
||||
return { archivedIds, failedIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel written to `time.archived` when restoring a session.
|
||||
*
|
||||
* The OpenCode server has no HTTP path to clear `time.archived` back to NULL:
|
||||
* `session.update` only applies the field when the payload carries a finite
|
||||
* number (`archived !== undefined`), so omitting the key is a no-op and `null`
|
||||
* is silently ignored. Writing `0` is the only value that makes every reader
|
||||
* treat the session as active again: the UI, the event reducer, and the
|
||||
* OpenCode app/TUI all classify archive state by truthiness of
|
||||
* `time.archived`, and `0` is falsy. The one place that still excludes such a
|
||||
* session is the server's own `time_archived IS NULL` list filter, so the
|
||||
* global session cache loads with the inclusive `archived` flag and splits
|
||||
* client-side instead of relying on that filter (see
|
||||
* `useGlobalSessionsStore.loadSessions`).
|
||||
*/
|
||||
const UNARCHIVED_TIMESTAMP = 0
|
||||
|
||||
/**
|
||||
* Restore one archived session back to the active list.
|
||||
*
|
||||
* Same contract as `archiveSession`: waits for server confirmation before
|
||||
* reconciling stores, and rejects stale runtimes so a response produced by a
|
||||
* previous runtime cannot mutate the current runtime's state. The global
|
||||
* session cache is updated directly (the sidebar reads active/archived
|
||||
* buckets from it); the live directory store is re-populated by the
|
||||
* authoritative `session.updated` event the server publishes for the update.
|
||||
*/
|
||||
export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise<boolean> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
try {
|
||||
const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
if (!restored) {
|
||||
throw new Error("session.update failed: server did not return the restored session")
|
||||
}
|
||||
if (restored.time?.archived) {
|
||||
throw new Error("session.update failed: server kept the session archived")
|
||||
}
|
||||
useGlobalSessionsStore.getState().upsertSession(restored)
|
||||
if (sessionDirectory) registerSessionDirectory(sessionId, sessionDirectory)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] unarchiveSession failed", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type UnarchiveSessionsOptions = {
|
||||
/**
|
||||
* Runtime key captured when the batch was confirmed. When supplied, the batch
|
||||
* stops as soon as the active runtime differs.
|
||||
*/
|
||||
expectedRuntimeKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore several archived sessions sequentially, preserving partial results.
|
||||
*
|
||||
* One failed session never blocks or erases the others: it is reported in
|
||||
* `failedIds` while the remaining IDs are still attempted. When
|
||||
* `expectedRuntimeKey` is supplied and the runtime changes mid-batch, the
|
||||
* already-confirmed sessions stay in `restoredIds` and every ID that was not
|
||||
* confirmed on the captured runtime is reported in `failedIds`, so callers keep
|
||||
* showing truthful partial-failure feedback.
|
||||
*/
|
||||
export async function unarchiveSessions(
|
||||
ids: string[],
|
||||
options?: UnarchiveSessionsOptions,
|
||||
): Promise<{ restoredIds: string[]; failedIds: string[] }> {
|
||||
const restoredIds: string[] = []
|
||||
const failedIds: string[] = []
|
||||
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
||||
|
||||
for (const [index, id] of ids.entries()) {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) {
|
||||
failedIds.push(...ids.slice(index))
|
||||
break
|
||||
}
|
||||
if (await unarchiveSession(id, expectedRuntimeKey)) restoredIds.push(id)
|
||||
else failedIds.push(id)
|
||||
}
|
||||
|
||||
return { restoredIds, failedIds }
|
||||
}
|
||||
|
||||
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
|
||||
|
||||
@@ -483,6 +483,15 @@ describe('archiveSessions option forwarding', () => {
|
||||
expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] });
|
||||
expect(updateSessionCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('unarchiveSessions honors expectedRuntimeKey instead of discarding the options object', async () => {
|
||||
const result = await useSessionUIStore.getState().unarchiveSessions(['session-x', 'session-y'], {
|
||||
expectedRuntimeKey: 'runtime-that-is-not-active',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ restoredIds: [], failedIds: ['session-x', 'session-y'] });
|
||||
expect(updateSessionCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSessions option forwarding', () => {
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
deleteSessions as deleteSessionsAction,
|
||||
archiveSession as archiveSessionAction,
|
||||
archiveSessions as archiveSessionsAction,
|
||||
unarchiveSession as unarchiveSessionAction,
|
||||
unarchiveSessions as unarchiveSessionsAction,
|
||||
updateSessionTitle as updateSessionTitleAction,
|
||||
shareSession as shareSessionAction,
|
||||
unshareSession as unshareSessionAction,
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
type ArchiveSessionsOptions,
|
||||
type DeleteSessionOptions,
|
||||
type DeleteSessionsOptions,
|
||||
type UnarchiveSessionsOptions,
|
||||
} from "./session-actions"
|
||||
import { useInputStore, type SyntheticContextPart } from "./input-store"
|
||||
import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore"
|
||||
@@ -335,6 +338,8 @@ export type SessionUIState = {
|
||||
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
|
||||
archiveSession: (id: string) => Promise<boolean>
|
||||
archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }>
|
||||
unarchiveSession: (id: string) => Promise<boolean>
|
||||
unarchiveSessions: (ids: string[], options?: UnarchiveSessionsOptions) => Promise<{ restoredIds: string[]; failedIds: string[] }>
|
||||
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
|
||||
shareSession: (sessionId: string) => Promise<Session | null>
|
||||
unshareSession: (sessionId: string) => Promise<Session | null>
|
||||
@@ -1423,6 +1428,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
|
||||
archiveSessions: (ids, options) => archiveSessionsAction(ids, options),
|
||||
|
||||
unarchiveSession: (id) => unarchiveSessionAction(id),
|
||||
|
||||
unarchiveSessions: (ids, options) => unarchiveSessionsAction(ids, options),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateSessionTitle — calls SDK, SSE event updates child store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user