* Revive an archived session so the web UI can reach it again. * * Behaviour: * - Active session: idempotent no-op (`resumed: false`). * - Non-archived inactive session: forwards to `resumeSession` without touching metadata. * - Archived session: validates that the agent has
(sessionId: string, namespace: string)
| 1196 | * needed to resume is missing. |
| 1197 | */ |
| 1198 | async reopenSession(sessionId: string, namespace: string): Promise<ReopenSessionResult> { |
| 1199 | const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) |
| 1200 | if (!access.ok) { |
| 1201 | return { |
| 1202 | type: 'error', |
| 1203 | message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', |
| 1204 | code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' |
| 1205 | } |
| 1206 | } |
| 1207 | |
| 1208 | const session = access.session |
| 1209 | const metadata = session.metadata |
| 1210 | |
| 1211 | if (session.active) { |
| 1212 | return { type: 'success', sessionId: access.sessionId, resumed: false } |
| 1213 | } |
| 1214 | |
| 1215 | const isArchived = metadata?.lifecycleState === 'archived' |
| 1216 | |
| 1217 | if (isArchived && metadata) { |
| 1218 | if (metadata.flavor === 'cursor' && !metadata.cursorSessionId) { |
| 1219 | const hasMessages = this.store.messages.getFirstMessages(access.sessionId, 1).length > 0 |
| 1220 | if (hasMessages) { |
| 1221 | return { |
| 1222 | type: 'incomplete', |
| 1223 | message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', |
| 1224 | missing: ['cursorSessionId'] |
| 1225 | } |
| 1226 | } |
| 1227 | } |
| 1228 | |
| 1229 | const archiveSnapshot = { |
| 1230 | lifecycleState: metadata.lifecycleState, |
| 1231 | archivedBy: metadata.archivedBy, |
| 1232 | archiveReason: metadata.archiveReason, |
| 1233 | lifecycleStateSince: metadata.lifecycleStateSince |
| 1234 | } |
| 1235 | |
| 1236 | let applied: { cursorSessionProtocol?: 'acp' | 'stream-json' } |
| 1237 | try { |
| 1238 | applied = await this.sessionCache.clearSessionArchiveMetadata(access.sessionId) |
| 1239 | } catch (error) { |
| 1240 | const message = error instanceof Error ? error.message : 'Failed to clear archive metadata' |
| 1241 | return { type: 'error', message, code: 'metadata_conflict' } |
| 1242 | } |
| 1243 | |
| 1244 | const resumeResult = await this.resumeSession(access.sessionId, namespace) |
| 1245 | if (resumeResult.type === 'error') { |
| 1246 | // Resume failed - put the archive flags back so the row stays archived in the UI |
| 1247 | // and the operator can retry. Best-effort: a concurrent metadata write that |
| 1248 | // succeeded between clear and restore (e.g. an unrelated rename) wins, in |
| 1249 | // which case we surface the original resume error rather than masking it. |
| 1250 | try { |
| 1251 | await this.sessionCache.restoreSessionArchiveMetadata(access.sessionId, archiveSnapshot) |
| 1252 | } catch { |
| 1253 | // Swallow restore failures - the resume error is the more important signal. |
| 1254 | } |
| 1255 | return resumeResult |
no test coverage detected