fix(sync): honor expectedRuntimeKey in archive actions
`useSessionUIStore.archiveSessions` declared an `options` parameter and discarded it, so any caller passing a captured runtime key got a silent no-op. The archive path also never rechecked the runtime, letting a response produced by a previous runtime reconcile the live and global session stores of the runtime the user had switched to. Move the batch to a canonical `archiveSessions()` action, add an optional `expectedRuntimeKey` to `archiveSession()`, `patchSessionMetadata()`, and `cleanupReviewMetadataBeforeDelete()`, and recheck that key before every store reconciliation. A guarded batch stops at the first observed runtime change: server-confirmed sessions stay in `archivedIds` and every unconfirmed ID is returned in `failedIds`, so existing partial-failure feedback stays truthful. Callers that pass no key keep prior behavior. Type the store option as `ArchiveSessionsOptions` instead of `Record<string, unknown>`, since the loose type allowed the drop.
This commit is contained in:
@@ -654,15 +654,29 @@ export async function createSession(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a caller captured a runtime key before an asynchronous mutation and
|
||||
* that runtime is no longer the active one. Callers pass `undefined` when they
|
||||
* do not participate in runtime-scoped guarding, which keeps the previous
|
||||
* unguarded behavior.
|
||||
*/
|
||||
function isStaleRuntime(expectedRuntimeKey: string | undefined): boolean {
|
||||
return expectedRuntimeKey !== undefined && getRuntimeKey() !== expectedRuntimeKey
|
||||
}
|
||||
|
||||
export async function patchSessionMetadata(
|
||||
sessionId: string,
|
||||
directory: string | null | undefined,
|
||||
updater: (metadata: SessionMetadataRecord) => SessionMetadataRecord,
|
||||
expectedRuntimeKey?: string,
|
||||
): Promise<Session> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed")
|
||||
const targetDirectory = directory ?? getSessionDirectory(sessionId)
|
||||
const current = await opencodeClient.getSession(sessionId, targetDirectory)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed")
|
||||
const nextMetadata = updater(getSessionMetadata(current))
|
||||
const updated = await opencodeClient.updateSession(sessionId, { metadata: nextMetadata }, targetDirectory)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed")
|
||||
useGlobalSessionsStore.getState().upsertSession(updated)
|
||||
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
|
||||
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
|
||||
@@ -682,19 +696,26 @@ export async function setContextObligatoryMessage(
|
||||
return updated
|
||||
}
|
||||
|
||||
async function cleanupReviewMetadataBeforeDelete(sessionId: string, directory?: string | null): Promise<void> {
|
||||
async function cleanupReviewMetadataBeforeDelete(
|
||||
sessionId: string,
|
||||
directory?: string | null,
|
||||
expectedRuntimeKey?: string,
|
||||
): Promise<void> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return
|
||||
let session: Session
|
||||
try {
|
||||
session = await opencodeClient.getSession(sessionId, directory ?? getSessionDirectory(sessionId))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return
|
||||
if (!isReviewSession(session)) return
|
||||
const originalSessionID = getOriginalSessionID(session)
|
||||
if (!originalSessionID) return
|
||||
try {
|
||||
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) =>
|
||||
withoutReviewSessionLink(metadata, sessionId),
|
||||
expectedRuntimeKey,
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -803,12 +824,26 @@ export async function deleteSessionInDirectory(sessionId: string, directory: str
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<boolean> {
|
||||
/**
|
||||
* Archive one session.
|
||||
*
|
||||
* `expectedRuntimeKey` is the runtime key the caller captured when the user
|
||||
* confirmed the operation. When it is supplied and the runtime changes, the
|
||||
* action stops and returns `false` without reconciling any store, so a response
|
||||
* produced by the previous runtime cannot mutate the current runtime's live or
|
||||
* global session state. A session the server already archived before the switch
|
||||
* stays archived on that runtime and is re-read from the server the next time
|
||||
* the runtime is loaded.
|
||||
*/
|
||||
export async function archiveSession(sessionId: string, expectedRuntimeKey?: string): Promise<boolean> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const archivedAt = Date.now()
|
||||
try {
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory)
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
if (!archived) {
|
||||
throw new Error("session.update failed: server did not return the archived session")
|
||||
}
|
||||
@@ -824,6 +859,45 @@ export async function archiveSession(sessionId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export type ArchiveSessionsOptions = {
|
||||
/**
|
||||
* Runtime key captured when the batch was confirmed. When supplied, the batch
|
||||
* stops as soon as the active runtime differs.
|
||||
*/
|
||||
expectedRuntimeKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive several 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 `archivedIds` and every ID that was not
|
||||
* confirmed on the captured runtime is reported in `failedIds`, so callers keep
|
||||
* showing the existing partial-failure feedback instead of silently dropping
|
||||
* work.
|
||||
*/
|
||||
export async function archiveSessions(
|
||||
ids: string[],
|
||||
options?: ArchiveSessionsOptions,
|
||||
): Promise<{ archivedIds: string[]; failedIds: string[] }> {
|
||||
const archivedIds: string[] = []
|
||||
const failedIds: string[] = []
|
||||
const expectedRuntimeKey = options?.expectedRuntimeKey
|
||||
|
||||
for (const [index, id] of ids.entries()) {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) {
|
||||
failedIds.push(...ids.slice(index))
|
||||
break
|
||||
}
|
||||
if (await archiveSession(id, expectedRuntimeKey)) archivedIds.push(id)
|
||||
else failedIds.push(id)
|
||||
}
|
||||
|
||||
return { archivedIds, failedIds }
|
||||
}
|
||||
|
||||
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
|
||||
|
||||
Reference in New Issue
Block a user