import type { IControllerPersistedSessionGroup, } from '../ts/interfaces.projects.js'; import type { TControllerLayoutItemRef, TControllerSessionId, } from '../ts_interfaces/index.js'; type TObject = Record; /** * The layout document as it was persisted before conversations and resources became peers: * every member was a conversation id, and resources were excluded outright by the v18 and v24 * migrations. Frozen here rather than imported from the live model, because the live assertion * now describes item refs and would reject every historical document. */ export interface IV30LegacySessionGroup { id: string; name: string; sessionIds: TControllerSessionId[]; } export interface IV30LegacySessionGroupsDocument { id: string; controllerId: string; projectId: string; groups: IV30LegacySessionGroup[]; ungroupedSessionIds?: TControllerSessionId[]; revision?: number; } /** * The per-project layout document this migration produces. It stopped being the live shape when * the layout became controller-wide in v32, which consumes exactly this as its input, so it is * frozen here next to the legacy shape it lifts from. */ export interface IV30ItemRefSessionGroupsDocument { id: string; controllerId: string; projectId: string; groups: IControllerPersistedSessionGroup[]; ungroupedItemIds?: TControllerLayoutItemRef[]; revision?: number; } export interface IV30LayoutMigrationResult { document: IV30ItemRefSessionGroupsDocument; migrated: boolean; } const isPlainObject = (valueArg: unknown): valueArg is TObject => { if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) return false; const prototype = Object.getPrototypeOf(valueArg); return prototype === Object.prototype || prototype === null; }; const hasExactKeys = ( valueArg: TObject, requiredArg: readonly string[], optionalArg: readonly string[] = [], ): boolean => { const allowed = new Set([...requiredArg, ...optionalArg]); return requiredArg.every((key) => Object.hasOwn(valueArg, key)) && Object.keys(valueArg).every((key) => allowed.has(key)); }; const isLegacySessionId = (valueArg: unknown): valueArg is TControllerSessionId => ( isPlainObject(valueArg) && hasExactKeys(valueArg, ['harnessId', 'nativeId']) && (valueArg.harnessId === 'opencode' || valueArg.harnessId === 'flex' || valueArg.harnessId === 'codex') && typeof valueArg.nativeId === 'string' && valueArg.nativeId.length > 0 ); /** * The layout assertion as it stood before item refs. The v6, v18 and v24 migrations normalize * historical documents into this shape and must keep validating against it: the live assertion * describes the post-lift shape they do not produce. */ export function assertLegacySessionLayoutDocument( valueArg: unknown, ): asserts valueArg is IV30LegacySessionGroupsDocument { if ( !isPlainObject(valueArg) || !hasExactKeys( valueArg, ['id', 'controllerId', 'projectId', 'groups'], ['ungroupedSessionIds', 'revision'], ) || typeof valueArg.controllerId !== 'string' || typeof valueArg.projectId !== 'string' || valueArg.id !== `${valueArg.controllerId}:groups:${valueArg.projectId}` || !Array.isArray(valueArg.groups) ) { throw new Error('Invalid legacy controller session groups document.'); } const memberKeys = new Set(); const groupIds = new Set(); for (const group of valueArg.groups) { if ( !isPlainObject(group) || !hasExactKeys(group, ['id', 'name', 'sessionIds']) || typeof group.id !== 'string' || group.id.length === 0 || groupIds.has(group.id) || typeof group.name !== 'string' || !Array.isArray(group.sessionIds) ) { throw new Error('Invalid legacy controller session groups document.'); } groupIds.add(group.id); for (const sessionId of group.sessionIds) { if (!isLegacySessionId(sessionId)) { throw new Error('Invalid legacy controller session groups document.'); } const key = `${sessionId.harnessId}:${sessionId.nativeId}`; if (memberKeys.has(key)) { throw new Error('Invalid legacy controller session groups document.'); } memberKeys.add(key); } } const hasUngrouped = valueArg.ungroupedSessionIds !== undefined; const hasRevision = valueArg.revision !== undefined; if (hasUngrouped !== hasRevision) { throw new Error('Invalid legacy controller session groups document.'); } if (!hasUngrouped) return; if ( !Number.isSafeInteger(valueArg.revision) || (valueArg.revision as number) < 0 || !Array.isArray(valueArg.ungroupedSessionIds) ) { throw new Error('Invalid legacy controller session groups document.'); } const ungroupedKeys = new Set(); for (const sessionId of valueArg.ungroupedSessionIds) { if (!isLegacySessionId(sessionId)) { throw new Error('Invalid legacy controller session groups document.'); } const key = `${sessionId.harnessId}:${sessionId.nativeId}`; if (memberKeys.has(key) || ungroupedKeys.has(key)) { throw new Error('Invalid legacy controller session groups document.'); } ungroupedKeys.add(key); } } /** * A document that already carries item refs must be left exactly as it is. * * Two shapes have to be told apart, and neither field alone does it: * - a group carrying `itemIds` is decisive on its own, because the legacy shape only ever had * `sessionIds`. This is the case that matters in practice: `ungroupedItemIds` and `revision` * are a paired *optional* pair, so a perfectly valid lifted document can omit both, and * demanding `ungroupedItemIds` made the v6, v18 and v24 migrations re-read such a document as * legacy on the next start and reject it. * - with no groups at all there is nothing to read a kind from, so the presence of the lifted * ordering field is the only signal; without it the document is legacy and must be lifted. */ export const isV30ItemRefLayoutDocument = ( valueArg: unknown, ): valueArg is IV30ItemRefSessionGroupsDocument => { if (!isPlainObject(valueArg)) return false; if (Object.hasOwn(valueArg, 'ungroupedSessionIds')) return false; if (!Array.isArray(valueArg.groups)) return false; if ( Object.hasOwn(valueArg, 'ungroupedItemIds') && !Array.isArray(valueArg.ungroupedItemIds) ) return false; if (valueArg.groups.length > 0) { return valueArg.groups.every( (group) => isPlainObject(group) && Array.isArray(group.itemIds), ); } return Object.hasOwn(valueArg, 'ungroupedItemIds'); }; /** * The per-project shape this migration writes. The live document assertion describes the * controller-wide layout of v32, so it cannot validate this intermediate shape. */ export function assertV30ItemRefSessionGroupsDocument( valueArg: unknown, ): asserts valueArg is IV30ItemRefSessionGroupsDocument { const invalid = () => new Error('Invalid v30 item-ref session groups document.'); if ( !isPlainObject(valueArg) || !hasExactKeys( valueArg, ['id', 'controllerId', 'projectId', 'groups'], ['ungroupedItemIds', 'revision'], ) || typeof valueArg.controllerId !== 'string' || typeof valueArg.projectId !== 'string' || valueArg.id !== `${valueArg.controllerId}:groups:${valueArg.projectId}` || !Array.isArray(valueArg.groups) || !valueArg.groups.every((group) => ( isPlainObject(group) && hasExactKeys(group, ['id', 'name', 'itemIds']) && typeof group.id === 'string' && typeof group.name === 'string' && Array.isArray(group.itemIds) )) || (Object.hasOwn(valueArg, 'ungroupedItemIds') && !Array.isArray(valueArg.ungroupedItemIds)) || ( Object.hasOwn(valueArg, 'revision') && (!Number.isSafeInteger(valueArg.revision) || (valueArg.revision as number) < 0) ) ) throw invalid(); } /** * Lifts a pre-item-ref layout document. * * Group membership and the ungrouped order become refs tagged with their kind, because * conversation ids and resource ids are separate namespaces. The project's resources are * appended to the ungrouped order in creation order, which is the order the sidebar derived for * them before they were orderable, so the first render after the upgrade looks unchanged. * * Documents that predate explicit ordering carry neither `ungroupedSessionIds` nor `revision`. * They gain both here — at revision zero, which is exactly how the store already reads a missing * revision — so the appended resources have a persisted order rather than none. */ export const migrateV30SessionLayoutDocument = ( valueArg: IV30LegacySessionGroupsDocument | IV30ItemRefSessionGroupsDocument, projectResourceIdsArg: readonly string[], ): IV30LayoutMigrationResult => { if (isV30ItemRefLayoutDocument(valueArg)) { return { document: valueArg, migrated: false }; } // Every ref carries its project, because the sidebar is becoming cross-project and these // documents are per project: the current document's project is the answer for all of them. const projectId = valueArg.projectId; const groups: IControllerPersistedSessionGroup[] = valueArg.groups.map((group) => ({ id: group.id, name: group.name, itemIds: group.sessionIds.map((sessionId) => ({ kind: 'session' as const, id: { ...sessionId }, projectId, })), })); const ungroupedItemIds: TControllerLayoutItemRef[] = (valueArg.ungroupedSessionIds ?? []).map( (sessionId) => ({ kind: 'session' as const, id: { ...sessionId }, projectId }), ); for (const resourceId of projectResourceIdsArg) { ungroupedItemIds.push({ kind: 'resource', id: resourceId, projectId }); } return { document: { id: valueArg.id, controllerId: valueArg.controllerId, projectId: valueArg.projectId, groups, ungroupedItemIds, revision: valueArg.revision ?? 0, }, migrated: true, }; };