import * as plugins from '../ts/plugins.js'; import type { IAGLHomeOptions, IAGLHomePaths, } from '../ts/classes.aglhome.js'; import { resolveAGLHomePaths } from '../ts/classes.aglhome.js'; import { readDatabaseConfig, type IControllerDataDirectoryOptions, } from '../ts/classes.config.js'; import { listControllerDataWriterProcessesForCliPaths, readControllerProcessIdentity, } from '../ts/classes.processinspection.js'; import { embeddedDatabaseSocketPath, isSocketListening } from '../ts/functions.embeddeddb.js'; import type { IControllerDatabaseConfig } from '../ts/interfaces.config.js'; import { ControllerDataRootMigrationRunner, type IControllerDataRootMigrationResult, type IControllerDataWriterProcessRecord, } from './v2_controllerdataroot.js'; import { flexProviderCredentialStoreId } from './v16_flexprovidercredentials.js'; const migrationVersion = 23 as const; const journalFileName = 'agl-home-v23.json'; const maximumJournalBytes = 256 * 1024; const maximumLockBytes = 4 * 1024; const maximumJournalTemporaryFiles = 16; const maximumLockTemporaryFiles = 16; const maximumRootEntries = 2_048; const maximumCredentialStores = 512; const maximumLegacyUploadRoots = 256; const maximumLegacyUploadNodes = 8_192; const maximumLegacyUploadDepth = 32; const controllerLogPattern = /^controller-(?:[1-9][0-9]{0,4})\.log(?:\.old)?$/; const upgradeLogPattern = /^upgrade-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}\.[0-9]{3}Z-[A-Za-z0-9_-]{10}\.log$/; const credentialHashPattern = /^[a-f0-9]{64}$/; const journalTemporaryPattern = /^agl-home-v23\.json\.[1-9][0-9]*-[a-f0-9]{16}\.tmp$/; const lockTemporaryPattern = /\.tmp-([0-9]+)-([1-9][0-9]*)-([a-f0-9]{64})$/; const legacyUploadRootPattern = /^harness-controller-uploads-[A-Za-z0-9]{6}$/; const legacyUploadTombstonePattern = /^harness-controller-uploads-[A-Za-z0-9]{6}\.agl-v23-([a-f0-9]{64})\.removing$/; const legacyUploadCleanupOperation = 'legacy-upload-roots'; const fixedOperationNames = new Set([ 'browser-runtime-active', 'database-relocation-receipt', 'git-reversion', 'legacy-browser-runtime-config', 'legacy-browser-runtime-state', 'legacy-database-backup-v1', legacyUploadCleanupOperation, 'legacy-v1-source-journal', 'legacy-v1-target-journal', 'legacy-v2-data-root-journal', 'legacy-v2-database-relocation-receipt', 'legacy-v2-target-marker', 'unused-local-database', ]); const isKnownOperationName = (valueArg: string): boolean => { if (fixedOperationNames.has(valueArg)) return true; if (/^credential:[a-f0-9]{64}$/.test(valueArg)) return true; const logMatch = /^log:(data|config|state):(.+)$/.exec(valueArg); return Boolean(logMatch && ( controllerLogPattern.test(logMatch[2]) || upgradeLogPattern.test(logMatch[2]) )); }; export const legacyEmbeddedDatabaseSocketPath = (dataDirectoryArg: string): string => { const directoryHash = plugins.crypto .createHash('sha256') .update(dataDirectoryArg) .digest('hex') .slice(0, 16); return plugins.path.join(plugins.os.tmpdir(), `harness-controller-${directoryHash}.sock`); }; export interface IAGLLegacyPaths { development: boolean; legacySourceEnabled: boolean; activeDataRoot: string; legacyConfigRoot?: string; legacyStateRoot?: string; v2JournalPath?: string; v2LockPath?: string; } const modulePackageRoot = plugins.path.resolve( plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url)), '..', ); const isDevelopmentCheckout = (packageRootArg: string): boolean => { try { const stats = plugins.fs.lstatSync(plugins.path.join(packageRootArg, '.git')); return stats.isDirectory() || stats.isFile(); } catch { return false; } }; export const resolveAGLLegacyPaths = ( optionsArg: IAGLHomeOptions = {}, ): IAGLLegacyPaths => { const environment = optionsArg.environment ?? process.env; const packageRoot = plugins.path.normalize(optionsArg.packageRoot ?? modulePackageRoot); if (isDevelopmentCheckout(packageRoot)) { const defaultRoot = plugins.path.join(packageRoot, '.nogit', 'agl'); const configuredRoot = environment.AGL_HOME?.trim(); const legacySourceEnabled = !configuredRoot || plugins.path.normalize(configuredRoot) === defaultRoot; return { development: true, legacySourceEnabled, activeDataRoot: plugins.path.join(packageRoot, '.nogit'), }; } const homeDirectory = plugins.path.normalize(optionsArg.homeDirectory ?? plugins.os.homedir()); const configuredConfigHome = environment.XDG_CONFIG_HOME?.trim(); const configHome = configuredConfigHome && plugins.path.isAbsolute(configuredConfigHome) ? plugins.path.normalize(configuredConfigHome) : plugins.path.join(homeDirectory, '.config'); const configuredStateHome = environment.XDG_STATE_HOME?.trim(); const stateHome = configuredStateHome && plugins.path.isAbsolute(configuredStateHome) ? plugins.path.normalize(configuredStateHome) : plugins.path.join(homeDirectory, '.local', 'state'); return { development: false, legacySourceEnabled: true, activeDataRoot: plugins.path.join(configHome, 'hcon'), legacyConfigRoot: plugins.path.join(configHome, 'harness-controller'), legacyStateRoot: plugins.path.join(stateHome, 'harness-controller'), v2JournalPath: plugins.path.join(configHome, '.hcon-controller-data-root-migration.json'), v2LockPath: plugins.path.join(configHome, '.hcon-controller-data-root-migration.lock'), }; }; export const readLegacyV2DatabaseConfig = ( optionsArg: IAGLHomeOptions = {}, ): IControllerDatabaseConfig => { const canonical = readDatabaseConfig(optionsArg); const environment = optionsArg.environment ?? process.env; if (canonical.mongoDbUrl !== undefined || environment.HARNESS_CONTROLLER_DB_DIR?.trim()) { return canonical; } const legacy = resolveAGLLegacyPaths(optionsArg); return { mongoDbName: canonical.mongoDbName, embeddedDataDirectory: plugins.path.join(legacy.activeDataRoot, 'smartdb'), ...(legacy.legacyStateRoot ? { legacyEmbeddedDataDirectory: plugins.path.join(legacy.legacyStateRoot, 'smartdb') } : {}), }; }; interface IFilesystemIdentity { device: string; inode: string; } interface ISourceIdentity extends IFilesystemIdentity { path: string; } interface ICredentialRelocationBinding { controllerHash: string; sourceDirectory: string; } interface ILegacyUploadRootBinding extends IFilesystemIdentity { path: string; } interface IAGLHomeMigrationJournal { version: typeof migrationVersion; phase: 'target-staged' | 'target-committed'; mode: 'development' | 'installed'; root: string; rootDevice: string; rootInode: string; nonce: string; sources: ISourceIdentity[]; credentialRelocations: ICredentialRelocationBinding[]; legacyUploadRoots: ILegacyUploadRootBinding[]; completedOperations: string[]; } interface ILockOwner { version: 1; pid: number; uid: number; fingerprint: string; nonce: string; } export interface IAGLHomeMigrationOptions { paths: IAGLHomePaths; legacyPaths: IAGLLegacyPaths; databaseConfig: IControllerDatabaseConfig; invokerPid: number; listDataWriterProcesses: () => Promise; isSocketListening?: (socketPathArg: string) => Promise; relocateStoppedDatabaseRoot?: ( inputArg: Readonly, signalArg?: AbortSignal, ) => Promise; relocateCredentialStore?: ( inputArg: { controllerHash: string; sourceDirectory: string; destinationDirectory: string; }, signalArg?: AbortSignal, ) => Promise; legacyUploadTempDirectory?: string; signal?: AbortSignal; } export interface IAGLHomeMigrationPreflightOptions { allowDataWriters?: boolean; } export interface IAGLHomeMigrationResult { directoryPath: string; databaseConfig: IControllerDatabaseConfig; } const currentUid = (): number => { if (typeof process.getuid !== 'function') { throw new Error('AGL home migration requires a POSIX user identity.'); } return process.getuid(); }; const isMissingError = (errorArg: unknown): boolean => ( (errorArg as NodeJS.ErrnoException).code === 'ENOENT' ); const identityFromStats = (statsArg: plugins.fs.BigIntStats): IFilesystemIdentity => ({ device: statsArg.dev.toString(10), inode: statsArg.ino.toString(10), }); const identitiesEqual = ( leftArg: IFilesystemIdentity, rightArg: IFilesystemIdentity, ): boolean => leftArg.device === rightArg.device && leftArg.inode === rightArg.inode; const lstatIfPresent = async (pathArg: string): Promise => { try { return await plugins.fs.promises.lstat(pathArg, { bigint: true }); } catch (errorArg) { if (isMissingError(errorArg)) return undefined; throw errorArg; } }; const assertOwnedNode = ( pathArg: string, statsArg: plugins.fs.BigIntStats, kindArg: 'file' | 'directory', privateRootArg = false, ): void => { if ( statsArg.isSymbolicLink() || (kindArg === 'file' ? !statsArg.isFile() : !statsArg.isDirectory()) || statsArg.uid !== BigInt(currentUid()) || (kindArg === 'file' && statsArg.nlink !== 1n) || (privateRootArg && Number(statsArg.mode & 0o077n) !== 0) ) throw new Error(`AGL migration path is unsafe: ${pathArg}`); }; const assertSafeAncestor = (pathArg: string, statsArg: plugins.fs.BigIntStats): void => { const mode = Number(statsArg.mode & 0o7777n); if ( statsArg.isSymbolicLink() || !statsArg.isDirectory() || ((mode & 0o022) !== 0 && (mode & 0o1000) === 0) ) throw new Error(`AGL home ancestor is unsafe: ${pathArg}`); }; const readBoundedDirectory = async (pathArg: string): Promise => { const names = await plugins.fs.promises.readdir(pathArg); if (names.length > maximumRootEntries) { throw new Error(`AGL migration directory has too many entries: ${pathArg}`); } return names.sort(); }; const syncDirectory = async (pathArg: string): Promise => { const handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_DIRECTORY, ); try { await handle.sync(); } finally { await handle.close(); } }; const assertSafeSourceTree = async ( rootArg: string, destinationDeviceArg?: string, ): Promise => { const pending = [rootArg]; let inspected = 0; while (pending.length > 0) { const path = pending.pop()!; const stats = await plugins.fs.promises.lstat(path, { bigint: true }); if ( stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile()) || stats.uid !== BigInt(currentUid()) || (stats.isFile() && stats.nlink !== 1n) || (destinationDeviceArg !== undefined && stats.dev.toString(10) !== destinationDeviceArg) ) { throw new Error(`AGL migration source tree is unsafe: ${path}`); } inspected += 1; if (inspected > 200_000) throw new Error('AGL migration source tree exceeds its node limit.'); if (!stats.isDirectory()) continue; for (const name of await readBoundedDirectory(path)) { pending.push(plugins.path.join(path, name)); } } }; const assertSafeLegacyUploadTree = async ( rootArg: string, destinationDeviceArg: string, ): Promise => { const pending = [{ path: rootArg, depth: 0 }]; let inspected = 0; while (pending.length > 0) { const current = pending.pop()!; const stats = await plugins.fs.promises.lstat(current.path, { bigint: true }); if ( stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile()) || stats.uid !== BigInt(currentUid()) || stats.dev.toString(10) !== destinationDeviceArg || (stats.isFile() && stats.nlink !== 1n) ) throw new Error(`Legacy controller upload tree is unsafe: ${current.path}`); inspected += 1; if (inspected > maximumLegacyUploadNodes) { throw new Error(`Legacy controller upload tree exceeds its node limit: ${rootArg}`); } if (!stats.isDirectory()) continue; const names = await readBoundedDirectory(current.path); if (names.length > 0 && current.depth >= maximumLegacyUploadDepth) { throw new Error(`Legacy controller upload tree exceeds its depth limit: ${rootArg}`); } for (const name of names) { pending.push({ path: plugins.path.join(current.path, name), depth: current.depth + 1 }); } } }; const nearestExistingDirectory = async (pathArg: string): Promise<{ path: string; identity: IFilesystemIdentity; }> => { let candidate = pathArg; while (true) { const stats = await lstatIfPresent(candidate); if (stats) { assertSafeAncestor(candidate, stats); const canonical = await plugins.fs.promises.realpath(candidate); if (canonical !== candidate) { throw new Error(`AGL home ancestor is not canonical: ${candidate}`); } return { path: candidate, identity: identityFromStats(stats) }; } const parent = plugins.path.dirname(candidate); if (parent === candidate) throw new Error('AGL home has no existing directory ancestor.'); candidate = parent; } }; const parseJournal = (valueArg: unknown): IAGLHomeMigrationJournal => { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) { throw new Error('AGL home migration journal is malformed.'); } const value = valueArg as Record; const keys = Object.keys(value).sort(); const expected = [ 'completedOperations', 'credentialRelocations', 'legacyUploadRoots', 'mode', 'nonce', 'phase', 'root', 'rootDevice', 'rootInode', 'sources', 'version', ].sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error('AGL home migration journal has unexpected fields.'); } if ( value.version !== migrationVersion || (value.phase !== 'target-staged' && value.phase !== 'target-committed') || (value.mode !== 'development' && value.mode !== 'installed') || typeof value.root !== 'string' || !plugins.path.isAbsolute(value.root) || plugins.path.normalize(value.root) !== value.root || typeof value.rootDevice !== 'string' || !/^[0-9]+$/.test(value.rootDevice) || typeof value.rootInode !== 'string' || !/^[0-9]+$/.test(value.rootInode) || typeof value.nonce !== 'string' || !/^[a-f0-9]{64}$/.test(value.nonce) || !Array.isArray(value.completedOperations) || value.completedOperations.length > 4_096 || !value.completedOperations.every((entry) => ( typeof entry === 'string' && entry.length <= 512 && isKnownOperationName(entry) )) || new Set(value.completedOperations).size !== value.completedOperations.length || (value.completedOperations as string[]).some((entry, index, entries) => ( index > 0 && entries[index - 1] > entry )) || !Array.isArray(value.sources) || value.sources.length > 8 || !Array.isArray(value.credentialRelocations) || value.credentialRelocations.length > maximumCredentialStores || !Array.isArray(value.legacyUploadRoots) || value.legacyUploadRoots.length > maximumLegacyUploadRoots ) throw new Error('AGL home migration journal is invalid.'); const sources = value.sources.map((entryArg): ISourceIdentity => { if (!entryArg || typeof entryArg !== 'object' || Array.isArray(entryArg)) { throw new Error('AGL home migration source binding is malformed.'); } const entry = entryArg as Record; if ( Object.keys(entry).sort().join('\0') !== ['device', 'inode', 'path'].sort().join('\0') || typeof entry.path !== 'string' || !plugins.path.isAbsolute(entry.path) || plugins.path.normalize(entry.path) !== entry.path || typeof entry.device !== 'string' || !/^[0-9]+$/.test(entry.device) || typeof entry.inode !== 'string' || !/^[0-9]+$/.test(entry.inode) ) throw new Error('AGL home migration source binding is invalid.'); return { path: entry.path, device: entry.device, inode: entry.inode }; }); const credentialRelocations = value.credentialRelocations.map( (entryArg, index, entries): ICredentialRelocationBinding => { if (!entryArg || typeof entryArg !== 'object' || Array.isArray(entryArg)) { throw new Error('AGL credential relocation binding is malformed.'); } const entry = entryArg as Record; if ( Object.keys(entry).sort().join('\0') !== ['controllerHash', 'sourceDirectory'].sort().join('\0') || typeof entry.controllerHash !== 'string' || !credentialHashPattern.test(entry.controllerHash) || typeof entry.sourceDirectory !== 'string' || !plugins.path.isAbsolute(entry.sourceDirectory) || plugins.path.normalize(entry.sourceDirectory) !== entry.sourceDirectory || (index > 0 && ((entries[index - 1] as Record).controllerHash as string) >= entry.controllerHash) ) throw new Error('AGL credential relocation binding is invalid.'); return { controllerHash: entry.controllerHash, sourceDirectory: entry.sourceDirectory, }; }, ); const legacyUploadRoots = value.legacyUploadRoots.map( (entryArg, index, entries): ILegacyUploadRootBinding => { if (!entryArg || typeof entryArg !== 'object' || Array.isArray(entryArg)) { throw new Error('Legacy controller upload root binding is malformed.'); } const entry = entryArg as Record; if ( Object.keys(entry).sort().join('\0') !== ['device', 'inode', 'path'].sort().join('\0') || typeof entry.path !== 'string' || !plugins.path.isAbsolute(entry.path) || plugins.path.normalize(entry.path) !== entry.path || !legacyUploadRootPattern.test(plugins.path.basename(entry.path)) || typeof entry.device !== 'string' || !/^[0-9]+$/.test(entry.device) || typeof entry.inode !== 'string' || !/^[0-9]+$/.test(entry.inode) || (index > 0 && ((entries[index - 1] as Record).path as string) >= entry.path) ) throw new Error('Legacy controller upload root binding is invalid.'); return { path: entry.path, device: entry.device, inode: entry.inode, }; }, ); return { version: migrationVersion, phase: value.phase, mode: value.mode, root: value.root, rootDevice: value.rootDevice, rootInode: value.rootInode, nonce: value.nonce, sources, credentialRelocations, legacyUploadRoots, completedOperations: [...value.completedOperations], }; }; const readPrivateJournal = async ( pathArg: string, ): Promise => { let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); } catch (errorArg) { if (isMissingError(errorArg)) return undefined; throw errorArg; } try { const stats = await handle.stat({ bigint: true }); if ( !stats.isFile() || stats.uid !== BigInt(currentUid()) || stats.nlink !== 1n || Number(stats.mode & 0o777n) !== 0o600 || stats.size < 2n || stats.size > BigInt(maximumJournalBytes) ) throw new Error('AGL home migration journal is not a private bounded file.'); return parseJournal(JSON.parse(await handle.readFile('utf8')) as unknown); } finally { await handle.close(); } }; const readPrivateLock = async ( pathArg: string, allowHardlinkArg = false, ): Promise<{ identity: IFilesystemIdentity; owner: ILockOwner } | undefined> => { let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); } catch (errorArg) { if (isMissingError(errorArg)) return undefined; throw errorArg; } try { const stats = await handle.stat({ bigint: true }); if ( !stats.isFile() || stats.uid !== BigInt(currentUid()) || (stats.nlink !== 1n && !(allowHardlinkArg && stats.nlink === 2n)) || Number(stats.mode & 0o777n) !== 0o600 || stats.size < 2n || stats.size > BigInt(maximumLockBytes) ) throw new Error('AGL home migration lock is not a private bounded file.'); const raw = JSON.parse(await handle.readFile('utf8')) as Record; if ( Object.keys(raw).sort().join('\0') !== ['fingerprint', 'nonce', 'pid', 'uid', 'version'].sort().join('\0') || raw.version !== 1 || raw.uid !== currentUid() || !Number.isSafeInteger(raw.pid) || (raw.pid as number) < 2 || typeof raw.fingerprint !== 'string' || raw.fingerprint.length < 1 || raw.fingerprint.length > 512 || typeof raw.nonce !== 'string' || !/^[a-f0-9]{64}$/.test(raw.nonce) ) throw new Error('AGL home migration lock is malformed.'); return { identity: identityFromStats(stats), owner: raw as unknown as ILockOwner, }; } finally { await handle.close(); } }; const closeKernelStore = async ( kernelStoreArg: plugins.smartsecret.SmartSecretKernelStore, ): Promise => { const errors: unknown[] = []; for (let attempt = 0; attempt < 2; attempt += 1) { try { await kernelStoreArg.close(); return; } catch (errorArg) { errors.push(errorArg); } } throw new AggregateError(errors, 'AGL credential kernel-store closure could not be confirmed.'); }; const retainedCredentialKernelStores = new Set(); const drainRetainedCredentialKernelStores = async (): Promise => { for (const kernelStore of retainedCredentialKernelStores) { await closeKernelStore(kernelStore); retainedCredentialKernelStores.delete(kernelStore); } }; const relocateCredentialStore = async ( inputArg: { controllerHash: string; sourceDirectory: string; destinationDirectory: string; }, signalArg?: AbortSignal, ): Promise => { signalArg?.throwIfAborted(); await drainRetainedCredentialKernelStores(); let kernelStore: plugins.smartsecret.SmartSecretKernelStore | undefined; let sealedStore: plugins.smartsecret.SmartSecretSealedFileStore | undefined; let operationError: unknown; try { kernelStore = await plugins.smartsecret.SmartSecretKernelStore.create({ service: `modelprofile.flexharness.${inputArg.controllerHash.slice(0, 32)}`, }); retainedCredentialKernelStores.add(kernelStore); sealedStore = await plugins.smartsecret.SmartSecretSealedFileStore.relocate({ kernelStore, storeId: flexProviderCredentialStoreId, sourceDirectoryPath: inputArg.sourceDirectory, destinationDirectoryPath: inputArg.destinationDirectory, }); await sealedStore.close(); sealedStore = undefined; } catch (errorArg) { operationError = errorArg; } if (sealedStore) { try { await sealedStore.close(); } catch (errorArg) { operationError = operationError ? new AggregateError([operationError, errorArg], 'AGL credential relocation cleanup failed.') : errorArg; } } if (kernelStore) { try { await closeKernelStore(kernelStore); retainedCredentialKernelStores.delete(kernelStore); } catch (errorArg) { operationError = operationError ? new AggregateError([operationError, errorArg], 'AGL credential relocation cleanup failed.') : errorArg; } } if (operationError) throw operationError; }; export class AGLHomeMigrationRunner { private readonly journalPath: string; private readonly lockPath: string; private readonly socketCheck: (socketPathArg: string) => Promise; private readonly relocateDatabase: NonNullable< IAGLHomeMigrationOptions['relocateStoppedDatabaseRoot'] >; private readonly relocateCredentials: NonNullable< IAGLHomeMigrationOptions['relocateCredentialStore'] >; private readonly legacyUploadTempDirectory: string; constructor(private readonly options: IAGLHomeMigrationOptions) { this.journalPath = plugins.path.join(options.paths.migration, journalFileName); this.lockPath = plugins.path.join( plugins.path.dirname(options.paths.root), `.${plugins.path.basename(options.paths.root)}-home-migration.lock`, ); this.socketCheck = options.isSocketListening ?? isSocketListening; this.relocateDatabase = options.relocateStoppedDatabaseRoot ?? (async (inputArg, signalArg) => plugins.smartdb.LocalSmartDb.relocateStoppedStorageRoot( { ...inputArg }, signalArg ? { signal: signalArg } : undefined, )); this.relocateCredentials = options.relocateCredentialStore ?? relocateCredentialStore; const configuredLegacyUploadTempDirectory = plugins.path.normalize( options.legacyUploadTempDirectory ?? plugins.os.tmpdir(), ); if ( !plugins.path.isAbsolute(configuredLegacyUploadTempDirectory) || plugins.path.parse(configuredLegacyUploadTempDirectory).root === configuredLegacyUploadTempDirectory || configuredLegacyUploadTempDirectory.includes('\0') || Buffer.byteLength(configuredLegacyUploadTempDirectory, 'utf8') > 4096 ) throw new Error('The legacy controller upload temp directory is invalid.'); this.legacyUploadTempDirectory = plugins.fs.realpathSync(configuredLegacyUploadTempDirectory); } public async isCommitted(): Promise { const journal = await readPrivateJournal(this.journalPath); if (!journal) return false; this.assertJournalBinding(journal); await this.assertJournalRootIdentity(journal); return journal.phase === 'target-committed'; } public async hasStarted(): Promise { const journal = await readPrivateJournal(this.journalPath); if (!journal) return false; this.assertJournalBinding(journal); await this.assertJournalRootIdentity(journal); return true; } /** Performs no writes and is safe to invoke before logs or admission state exist. */ public async preflight(optionsArg: IAGLHomeMigrationPreflightOptions = {}): Promise { this.options.signal?.throwIfAborted(); if (this.options.invokerPid !== process.pid) { throw new Error('AGL home migration invoker PID is not the current process.'); } if (plugins.path.dirname(this.options.paths.root) === this.options.paths.root) { throw new Error('AGL_HOME cannot be a filesystem root.'); } const destinationParent = await lstatIfPresent(plugins.path.dirname(this.options.paths.root)); if (!destinationParent) { throw new Error('The direct parent of AGL_HOME must already exist.'); } assertSafeAncestor(plugins.path.dirname(this.options.paths.root), destinationParent); const destinationAncestor = await nearestExistingDirectory(this.options.paths.root); const rootStats = await lstatIfPresent(this.options.paths.root); if (rootStats) assertOwnedNode(this.options.paths.root, rootStats, 'directory', true); const journal = await readPrivateJournal(this.journalPath); if (journal) { this.assertJournalBinding(journal); await this.assertJournalRootIdentity(journal, rootStats); for (const source of journal.sources) { const stats = await lstatIfPresent(source.path); if (stats && !identitiesEqual(identityFromStats(stats), source)) { throw new Error(`AGL migration source identity changed: ${source.path}`); } } if (journal.completedOperations.includes(legacyUploadCleanupOperation)) { await this.assertNoLegacyUploadRoots(journal); } } if (rootStats && journal?.phase !== 'target-committed') { await assertSafeSourceTree(this.options.paths.root, destinationAncestor.identity.device); } await this.inspectTargetInventory(journal !== undefined); const sourceNodes = await this.inspectSourceInventories(); if (journal?.phase === 'target-committed' && sourceNodes.length > 0) { throw new Error('Legacy AGL state appeared after the target became authoritative.'); } for (const source of sourceNodes) { if (await plugins.fs.promises.realpath(source.path) !== source.path) { throw new Error(`AGL migration source path is not canonical: ${source.path}`); } if (source.identity.device !== destinationAncestor.identity.device) { throw new Error( `AGL home migration cannot cross filesystems: ${source.path} -> ${this.options.paths.root}`, ); } await assertSafeSourceTree(source.path, destinationAncestor.identity.device); } await this.assertGitStateRelocatable(); if (!optionsArg.allowDataWriters) await this.assertNoConflictingWriters(); if (this.usesDefaultEmbeddedDatabase()) { const directories = [this.options.paths.database]; if (this.options.legacyPaths.legacySourceEnabled) { directories.unshift(plugins.path.join(this.options.legacyPaths.activeDataRoot, 'smartdb')); } if (this.options.legacyPaths.legacyStateRoot) { directories.unshift(plugins.path.join(this.options.legacyPaths.legacyStateRoot, 'smartdb')); } for (const directory of directories) { if ( journal?.phase === 'target-committed' && directory === this.options.paths.database ) continue; const socketPath = directory === this.options.paths.database ? embeddedDatabaseSocketPath(directory, this.options.paths.sockets) : legacyEmbeddedDatabaseSocketPath(directory); if (await this.socketCheck(socketPath)) { throw new Error(`AGL database migration is blocked by an active socket: ${directory}`); } } } } public async run(): Promise { await this.preflight(); const lock = await this.acquireLock(); let operationError: unknown; let result: IAGLHomeMigrationResult | undefined; try { await this.cleanupJournalTemporaryFiles(); await this.preflight(); result = await this.runLocked(); } catch (errorArg) { operationError = errorArg; } try { await this.releaseLock(lock); } catch (errorArg) { operationError = operationError ? new AggregateError([operationError, errorArg], 'AGL home migration and lock cleanup failed.') : errorArg; } if (operationError) throw operationError; return result!; } private usesDefaultEmbeddedDatabase(): boolean { return this.options.databaseConfig.mongoDbUrl === undefined && this.options.databaseConfig.embeddedDataDirectory === this.options.paths.database; } private isConfiguredDatabaseSource(pathArg: string): boolean { return this.options.databaseConfig.mongoDbUrl === undefined && this.options.databaseConfig.embeddedDataDirectory !== undefined && plugins.path.normalize(this.options.databaseConfig.embeddedDataDirectory) !== plugins.path.normalize(this.options.paths.database) && plugins.path.normalize(this.options.databaseConfig.embeddedDataDirectory) === plugins.path.normalize(pathArg); } private assertJournalBinding(journalArg: IAGLHomeMigrationJournal): void { if ( journalArg.root !== this.options.paths.root || journalArg.mode !== (this.options.legacyPaths.development ? 'development' : 'installed') ) throw new Error('AGL home migration journal belongs to another home or runtime mode.'); const sourceRoots = new Set([ ...(this.options.legacyPaths.legacySourceEnabled ? [plugins.path.join(this.options.legacyPaths.activeDataRoot, 'flex-provider-credentials')] : []), ...(this.options.legacyPaths.legacyConfigRoot ? [plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'flex-provider-credentials')] : []), ]); for (const binding of journalArg.credentialRelocations) { if (!sourceRoots.has(plugins.path.dirname(binding.sourceDirectory))) { throw new Error('AGL credential relocation binding belongs to another source root.'); } if (plugins.path.basename(binding.sourceDirectory) !== binding.controllerHash) { throw new Error('AGL credential relocation binding does not match its controller hash.'); } } for (const binding of journalArg.legacyUploadRoots) { if ( plugins.path.dirname(binding.path) !== this.legacyUploadTempDirectory || !legacyUploadRootPattern.test(plugins.path.basename(binding.path)) ) throw new Error('Legacy controller upload root binding belongs to another temp directory.'); } const boundHashes = new Set( journalArg.credentialRelocations.map((binding) => binding.controllerHash), ); for (const operation of journalArg.completedOperations) { if (operation.startsWith('credential:') && !boundHashes.has(operation.slice('credential:'.length))) { throw new Error('Completed AGL credential relocation has no source binding.'); } } } private async assertJournalRootIdentity( journalArg: IAGLHomeMigrationJournal, statsArg?: plugins.fs.BigIntStats, ): Promise { const stats = statsArg ?? await lstatIfPresent(this.options.paths.root); if ( !stats || !stats.isDirectory() || !identitiesEqual(identityFromStats(stats), { device: journalArg.rootDevice, inode: journalArg.rootInode, }) ) throw new Error('AGL home root identity changed after migration staging.'); } private async inspectTargetInventory(hasJournalArg: boolean): Promise { const root = await lstatIfPresent(this.options.paths.root); if (!root) return; const allowed = new Set([ 'database', 'credentials', 'git-reversion', 'logs', 'upgrade', 'cache', 'runtime', 'migration', 'identity', ]); for (const name of await readBoundedDirectory(this.options.paths.root)) { if (!allowed.has(name)) throw new Error(`Unexpected entry in AGL_HOME: ${name}`); const path = plugins.path.join(this.options.paths.root, name); const stats = await plugins.fs.promises.lstat(path, { bigint: true }); assertOwnedNode(path, stats, 'directory', true); if (!hasJournalArg && name !== 'upgrade' && name !== 'migration') { throw new Error(`Unjournaled AGL_HOME state is ambiguous: ${path}`); } } const migrationStats = await lstatIfPresent(this.options.paths.migration); if (migrationStats) { const migrationNames = await readBoundedDirectory(this.options.paths.migration); const allowedNames = hasJournalArg ? new Set([ journalFileName, 'active-database-relocation-receipt.json', 'legacy-unused-local-database', 'legacy-database-backup-v1', 'legacy-v2-target-marker.json', 'legacy-v1-target-journal.json', 'legacy-v1-source-journal.json', 'legacy-v2-database-relocation-receipt.json', 'legacy-browser-runtime-config', 'legacy-browser-runtime-state', 'legacy-v2-data-root-journal.json', ]) : new Set(); const journalTemporaryNames = migrationNames.filter((name) => journalTemporaryPattern.test(name)); if (journalTemporaryNames.length > maximumJournalTemporaryFiles) { throw new Error('AGL migration directory contains too many journal artifacts.'); } if ( (hasJournalArg && !migrationNames.includes(journalFileName)) || migrationNames.some((name) => ( !allowedNames.has(name) && !journalTemporaryPattern.test(name) )) ) throw new Error('AGL migration directory inventory is invalid.'); for (const name of journalTemporaryNames) { const path = plugins.path.join(this.options.paths.migration, name); const stats = await plugins.fs.promises.lstat(path, { bigint: true }); if ( !stats.isFile() || stats.isSymbolicLink() || stats.uid !== BigInt(currentUid()) || stats.nlink !== 1n || Number(stats.mode & 0o777n) !== 0o600 || stats.size > BigInt(maximumJournalBytes) ) throw new Error(`AGL journal artifact is unsafe: ${path}`); } } } private async inspectSourceInventories(): Promise> { const nodes: Array<{ path: string; identity: IFilesystemIdentity }> = []; const inspectRoot = async ( rootArg: string | undefined, allowedArg: ReadonlySet, dedicatedArg: boolean, ): Promise => { if (!rootArg) return; const root = await lstatIfPresent(rootArg); if (!root) return; assertOwnedNode(rootArg, root, 'directory', dedicatedArg); const names = await readBoundedDirectory(rootArg); for (const name of names) { if (!allowedArg.has(name)) { if (dedicatedArg) throw new Error(`Unknown AGL legacy entry: ${plugins.path.join(rootArg, name)}`); continue; } const path = plugins.path.join(rootArg, name); if (this.isConfiguredDatabaseSource(path)) continue; const stats = await plugins.fs.promises.lstat(path, { bigint: true }); if (stats.isSymbolicLink() || stats.uid !== BigInt(currentUid())) { throw new Error(`AGL legacy entry is unsafe: ${path}`); } nodes.push({ path, identity: identityFromStats(stats) }); } }; const dataAllowed = new Set([ '.hcon-controller-data-root-marker.json', '.smartdb-location-migration.json', 'smartdb', 'flex-provider-credentials', 'git-reversion', 'browser-runtime', ]); if (this.options.legacyPaths.development) { for (const name of await this.listLegacyLogNames(this.options.legacyPaths.activeDataRoot)) { dataAllowed.add(name); } } if (this.options.legacyPaths.legacySourceEnabled) { await inspectRoot( this.options.legacyPaths.activeDataRoot, dataAllowed, !this.options.legacyPaths.development, ); } const configAllowed = new Set([ '.smartdb-location-migration.json', 'smartdb', 'flex-provider-credentials', 'browser-runtime', ]); for (const name of await this.listLegacyLogNames(this.options.legacyPaths.legacyConfigRoot)) { configAllowed.add(name); } await inspectRoot(this.options.legacyPaths.legacyConfigRoot, configAllowed, true); const stateAllowed = new Set(['smartdb', 'browser-runtime']); for (const name of await this.listLegacyLogNames(this.options.legacyPaths.legacyStateRoot)) { stateAllowed.add(name); } await inspectRoot(this.options.legacyPaths.legacyStateRoot, stateAllowed, true); for (const path of [this.options.legacyPaths.v2JournalPath]) { if (!path) continue; const stats = await lstatIfPresent(path); if (!stats) continue; assertOwnedNode(path, stats, 'file', true); nodes.push({ path, identity: identityFromStats(stats) }); } if (this.options.legacyPaths.v2LockPath) { const lock = await lstatIfPresent(this.options.legacyPaths.v2LockPath); if (lock) throw new Error('The legacy controller data-root migration lock is still present.'); const parent = plugins.path.dirname(this.options.legacyPaths.v2LockPath); const base = plugins.path.basename(this.options.legacyPaths.v2LockPath); for (const name of await readBoundedDirectory(parent)) { if (name.startsWith(`${base}.`)) { throw new Error(`A legacy controller data-root migration lock artifact remains: ${name}`); } } } return nodes; } private async listLegacyLogNames(rootArg: string | undefined): Promise { if (!rootArg || !await lstatIfPresent(rootArg)) return []; return (await readBoundedDirectory(rootArg)).filter((name) => ( controllerLogPattern.test(name) || upgradeLogPattern.test(name) )); } private async assertNoConflictingWriters(): Promise { const invoker = await readControllerProcessIdentity(this.options.invokerPid); if (!invoker) throw new Error('AGL home migration invoker identity cannot be verified.'); const records = await this.options.listDataWriterProcesses(); if (!Array.isArray(records) || records.length > 1_024) { throw new Error('AGL data-writer process inventory is invalid.'); } for (const record of records) { if ( record.identity.pid === invoker.pid && record.identity.fingerprint === invoker.fingerprint ) continue; throw new Error(`AGL home migration is blocked by ${record.kind} writer PID ${record.identity.pid}.`); } } private async assertGitStateRelocatable(): Promise { if (!this.options.legacyPaths.legacySourceEnabled) return; const root = plugins.path.join(this.options.legacyPaths.activeDataRoot, 'git-reversion'); if (!await lstatIfPresent(root)) return; await assertSafeSourceTree(root); for (const owner of await readBoundedDirectory(root)) { if (!credentialHashPattern.test(owner)) { throw new Error(`Unexpected AGL Git reversion owner directory: ${owner}`); } const ownerRoot = plugins.path.join(root, owner); const ownerStats = await plugins.fs.promises.lstat(ownerRoot, { bigint: true }); assertOwnedNode(ownerRoot, ownerStats, 'directory'); for (const name of ['worktrees', 'worktree-records']) { const directory = plugins.path.join(ownerRoot, name); const stats = await lstatIfPresent(directory); if (stats && (await readBoundedDirectory(directory)).length > 0) { throw new Error(`AGL home migration is blocked by active Git worktree state: ${directory}`); } } } } private async runLocked(): Promise { let journal = await readPrivateJournal(this.journalPath); if (journal?.phase === 'target-committed') { this.assertJournalBinding(journal); await this.assertJournalRootIdentity(journal); return { directoryPath: this.options.paths.root, databaseConfig: { ...this.options.databaseConfig }, }; } await plugins.fs.promises.mkdir(this.options.paths.root, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(this.options.paths.root, 0o700); await plugins.fs.promises.mkdir(this.options.paths.migration, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(this.options.paths.migration, 0o700); if (!journal) { const rootIdentity = identityFromStats(await plugins.fs.promises.lstat( this.options.paths.root, { bigint: true }, )); const sources: ISourceIdentity[] = []; for (const path of [ this.options.legacyPaths.legacySourceEnabled ? this.options.legacyPaths.activeDataRoot : undefined, this.options.legacyPaths.legacyConfigRoot, this.options.legacyPaths.legacyStateRoot, ]) { if (!path) continue; const stats = await lstatIfPresent(path); if (stats) sources.push({ path, ...identityFromStats(stats) }); } journal = { version: migrationVersion, phase: 'target-staged', mode: this.options.legacyPaths.development ? 'development' : 'installed', root: this.options.paths.root, rootDevice: rootIdentity.device, rootInode: rootIdentity.inode, nonce: plugins.crypto.randomBytes(32).toString('hex'), sources, credentialRelocations: [], legacyUploadRoots: [], completedOperations: [], }; await this.writeJournal(journal); } this.assertJournalBinding(journal); await this.migrateDatabase(journal); await this.migrateCredentials(journal); if (this.options.legacyPaths.legacySourceEnabled) { await this.moveDirect( plugins.path.join(this.options.legacyPaths.activeDataRoot, 'git-reversion'), this.options.paths.gitReversion, 'git-reversion', 'directory', journal, ); await this.moveDirect( plugins.path.join(this.options.legacyPaths.activeDataRoot, 'browser-runtime'), this.options.paths.browserRuntime, 'browser-runtime-active', 'directory', journal, ); } for (const [root, category] of [ [ this.options.legacyPaths.legacySourceEnabled ? this.options.legacyPaths.activeDataRoot : undefined, 'data', ], [this.options.legacyPaths.legacyConfigRoot, 'config'], [this.options.legacyPaths.legacyStateRoot, 'state'], ] as const) { await this.migrateLogs(root, category, journal); } await this.moveLegacyMetadata(journal); await this.cleanupLegacyUploadRoots(journal); await this.cleanupLegacyRoots(); if ((await this.inspectSourceInventories()).length > 0) { throw new Error('AGL legacy source inventory is not empty after migration.'); } for (const directory of [ this.options.paths.database, this.options.paths.credentials, this.options.paths.gitReversion, this.options.paths.logs, this.options.paths.legacyLogs, this.options.paths.upgrade, this.options.paths.cache, this.options.paths.runtime, this.options.paths.mcpRuntime, this.options.paths.browserRuntime, this.options.paths.uploads, this.options.paths.sockets, this.options.paths.openCodeRuntime, this.options.paths.migration, ]) { await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(directory, 0o700); } await this.inspectTargetInventory(true); await assertSafeSourceTree(this.options.paths.root, journal.rootDevice); journal = { ...journal, phase: 'target-committed' }; await this.writeJournal(journal); return { directoryPath: this.options.paths.root, databaseConfig: { ...this.options.databaseConfig }, }; } private async migrateDatabase(journalArg: IAGLHomeMigrationJournal): Promise { const source = plugins.path.join(this.options.legacyPaths.activeDataRoot, 'smartdb'); const sourceStats = this.options.legacyPaths.legacySourceEnabled ? await lstatIfPresent(source) : undefined; const destinationStats = await lstatIfPresent(this.options.paths.database); if (this.usesDefaultEmbeddedDatabase()) { if (sourceStats?.isDirectory()) { const receipt = await this.relocateDatabase({ sourceFolderPath: source, destinationFolderPath: this.options.paths.database, relocationId: `agl-home-v23:${journalArg.nonce}:database`, }, this.options.signal); if ( receipt.sourceFolderPath !== source || receipt.destinationFolderPath !== this.options.paths.database || receipt.sourceReceiptRetained !== true || receipt.storageRootDevice !== sourceStats.dev.toString(10) || receipt.storageRootInode !== sourceStats.ino.toString(10) || !/^[a-f0-9]{64}$/.test(receipt.providerRootId) || !/^[a-f0-9]{64}$/.test(receipt.receiptSha256) ) throw new Error('AGL SmartDB relocation receipt does not match its operation.'); const relocated = await plugins.fs.promises.lstat( this.options.paths.database, { bigint: true }, ); if ( !relocated.isDirectory() || relocated.dev.toString(10) !== receipt.storageRootDevice || relocated.ino.toString(10) !== receipt.storageRootInode ) throw new Error('AGL SmartDB destination identity does not match its receipt.'); const retainedReceiptStats = await plugins.fs.promises.lstat(source, { bigint: true }); if ( !retainedReceiptStats.isFile() || retainedReceiptStats.uid !== BigInt(currentUid()) || Number(retainedReceiptStats.mode & 0o777n) !== 0o600 || retainedReceiptStats.size < 2n || retainedReceiptStats.size > 16_384n ) throw new Error('AGL SmartDB retained receipt is unsafe.'); const retainedReceipt = JSON.parse( await plugins.fs.promises.readFile(source, 'utf8'), ) as Record; for (const key of [ 'format', 'version', 'relocationId', 'sourceFolderPath', 'destinationFolderPath', 'providerRootId', 'storageRootDevice', 'storageRootInode', 'receiptSha256', 'sourceReceiptRetained', ] as const) { if (retainedReceipt[key] !== receipt[key]) { throw new Error('AGL SmartDB retained receipt does not match its API receipt.'); } } } else if (sourceStats && !sourceStats.isFile()) { throw new Error(`Legacy AGL database path is unsafe: ${source}`); } else if (!sourceStats && destinationStats && !destinationStats.isDirectory()) { throw new Error('AGL database destination is not a directory.'); } else if (sourceStats?.isFile() && !destinationStats) { throw new Error('AGL database relocation receipt exists without its destination.'); } await this.moveDirect( source, plugins.path.join(this.options.paths.migration, 'active-database-relocation-receipt.json'), 'database-relocation-receipt', 'file', journalArg, ); } else if (!this.isConfiguredDatabaseSource(source) && (sourceStats || destinationStats)) { await this.moveDirect( source, plugins.path.join(this.options.paths.migration, 'legacy-unused-local-database'), 'unused-local-database', sourceStats?.isFile() ? 'file' : 'directory', journalArg, ); } if (this.options.legacyPaths.legacyStateRoot) { const historicalDatabase = plugins.path.join( this.options.legacyPaths.legacyStateRoot, 'smartdb', ); if (this.isConfiguredDatabaseSource(historicalDatabase)) return; await this.moveDirect( historicalDatabase, plugins.path.join(this.options.paths.migration, 'legacy-database-backup-v1'), 'legacy-database-backup-v1', 'directory', journalArg, ); } } private async migrateCredentials(journalArg: IAGLHomeMigrationJournal): Promise { const destinationRoot = plugins.path.join( this.options.paths.credentials, 'flex-provider-credentials', ); const sourceRoots = [ ...(this.options.legacyPaths.legacySourceEnabled ? [plugins.path.join(this.options.legacyPaths.activeDataRoot, 'flex-provider-credentials')] : []), ...(this.options.legacyPaths.legacyConfigRoot ? [plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'flex-provider-credentials')] : []), ]; const sourceByHash = new Map(); for (const root of sourceRoots) { const stats = await lstatIfPresent(root); if (!stats) continue; assertOwnedNode(root, stats, 'directory', true); const hashes = await readBoundedDirectory(root); if (hashes.length > maximumCredentialStores) { throw new Error('Legacy AGL credential root has too many stores.'); } for (const hash of hashes) { if (!credentialHashPattern.test(hash) || sourceByHash.has(hash)) { throw new Error(`Legacy AGL credential store is ambiguous: ${hash}`); } sourceByHash.set(hash, plugins.path.join(root, hash)); } } const destinationHashes: string[] = []; const destinationRootStats = await lstatIfPresent(destinationRoot); if (destinationRootStats) { assertOwnedNode(destinationRoot, destinationRootStats, 'directory', true); for (const hash of await readBoundedDirectory(destinationRoot)) { if (!credentialHashPattern.test(hash)) { throw new Error(`AGL credential destination contains an unexpected entry: ${hash}`); } destinationHashes.push(hash); } } const bindingByHash = new Map( journalArg.credentialRelocations.map((binding) => [binding.controllerHash, binding]), ); const hashes = [...new Set([ ...sourceByHash.keys(), ...destinationHashes, ...bindingByHash.keys(), ])].sort(); if (hashes.length > maximumCredentialStores) { throw new Error('AGL credential migration contains too many stores.'); } if (hashes.length > 0 && !destinationRootStats) { await plugins.fs.promises.mkdir(destinationRoot, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(this.options.paths.credentials, 0o700); await plugins.fs.promises.chmod(destinationRoot, 0o700); } for (const hash of hashes) { let binding = bindingByHash.get(hash); const observedSource = sourceByHash.get(hash); if (binding && observedSource && binding.sourceDirectory !== observedSource) { throw new Error(`AGL credential source changed after relocation intent: ${hash}`); } if (!binding) { if (!observedSource) { throw new Error(`AGL credential destination has no relocation intent: ${hash}`); } binding = { controllerHash: hash, sourceDirectory: observedSource }; journalArg.credentialRelocations.push(binding); journalArg.credentialRelocations.sort((left, right) => ( left.controllerHash.localeCompare(right.controllerHash) )); bindingByHash.set(hash, binding); await this.writeJournal(journalArg); } const source = binding.sourceDirectory; const destination = plugins.path.join(destinationRoot, hash); const sourceStats = await lstatIfPresent(source); const destinationStats = await lstatIfPresent(destination); if (sourceStats && destinationStats) { throw new Error(`AGL credential store exists at both source and destination: ${hash}`); } if (sourceStats) assertOwnedNode(source, sourceStats, 'directory', true); if (destinationStats) assertOwnedNode(destination, destinationStats, 'directory', true); const operation = `credential:${hash}`; if (journalArg.completedOperations.includes(operation)) { if (sourceStats || !destinationStats) { throw new Error(`Completed AGL credential relocation has an invalid location: ${hash}`); } continue; } if (!sourceStats && !destinationStats) { throw new Error(`AGL credential relocation has no source or destination: ${hash}`); } await this.relocateCredentials({ controllerHash: hash, sourceDirectory: source, destinationDirectory: destination, }, this.options.signal); if (await lstatIfPresent(source)) { throw new Error(`AGL credential source remains after relocation: ${hash}`); } const migrated = await lstatIfPresent(destination); if (!migrated) throw new Error(`AGL credential relocation was not confirmed: ${hash}`); assertOwnedNode(destination, migrated, 'directory', true); await this.markOperation(journalArg, operation); } } private async migrateLogs( rootArg: string | undefined, categoryArg: string, journalArg: IAGLHomeMigrationJournal, ): Promise { if (!rootArg || !await lstatIfPresent(rootArg)) return; for (const name of await this.listLegacyLogNames(rootArg)) { await this.moveDirect( plugins.path.join(rootArg, name), plugins.path.join(this.options.paths.legacyLogs, categoryArg, name), `log:${categoryArg}:${name}`, 'file', journalArg, ); } } private async moveLegacyMetadata(journalArg: IAGLHomeMigrationJournal): Promise { const legacyConfigDatabase = this.options.legacyPaths.legacyConfigRoot ? plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'smartdb') : undefined; const moves: Array<[string | undefined, string, string, 'file' | 'directory']> = [ [ this.options.legacyPaths.legacySourceEnabled ? plugins.path.join( this.options.legacyPaths.activeDataRoot, '.hcon-controller-data-root-marker.json', ) : undefined, 'legacy-v2-target-marker.json', 'legacy-v2-target-marker', 'file', ], [ this.options.legacyPaths.legacySourceEnabled ? plugins.path.join( this.options.legacyPaths.activeDataRoot, '.smartdb-location-migration.json', ) : undefined, 'legacy-v1-target-journal.json', 'legacy-v1-target-journal', 'file', ], [ this.options.legacyPaths.legacyConfigRoot ? plugins.path.join(this.options.legacyPaths.legacyConfigRoot, '.smartdb-location-migration.json') : undefined, 'legacy-v1-source-journal.json', 'legacy-v1-source-journal', 'file', ], [ legacyConfigDatabase && !this.isConfiguredDatabaseSource(legacyConfigDatabase) ? legacyConfigDatabase : undefined, 'legacy-v2-database-relocation-receipt.json', 'legacy-v2-database-relocation-receipt', 'file', ], [ this.options.legacyPaths.legacyConfigRoot ? plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'browser-runtime') : undefined, 'legacy-browser-runtime-config', 'legacy-browser-runtime-config', 'directory', ], [ this.options.legacyPaths.legacyStateRoot ? plugins.path.join(this.options.legacyPaths.legacyStateRoot, 'browser-runtime') : undefined, 'legacy-browser-runtime-state', 'legacy-browser-runtime-state', 'directory', ], [ this.options.legacyPaths.v2JournalPath, 'legacy-v2-data-root-journal.json', 'legacy-v2-data-root-journal', 'file', ], ]; for (const [source, destinationName, operation, kind] of moves) { if (!source) continue; await this.moveDirect( source, plugins.path.join(this.options.paths.migration, destinationName), operation, kind, journalArg, ); } } private legacyUploadTombstonePath( bindingArg: ILegacyUploadRootBinding, journalArg: IAGLHomeMigrationJournal, ): string { return `${bindingArg.path}.agl-v23-${journalArg.nonce}.removing`; } private async scanLegacyUploadRoots(journalArg: IAGLHomeMigrationJournal): Promise<{ sources: string[]; tombstones: Map; }> { const parentStats = await plugins.fs.promises.lstat(this.legacyUploadTempDirectory); if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) { throw new Error('The legacy controller upload temp directory is unsafe.'); } const sources: string[] = []; const tombstones = new Map(); let ownedMatches = 0; const directory = await plugins.fs.promises.opendir(this.legacyUploadTempDirectory); for await (const entry of directory) { const tombstoneMatch = legacyUploadTombstonePattern.exec(entry.name); if (!legacyUploadRootPattern.test(entry.name) && !tombstoneMatch) continue; const path = plugins.path.join(this.legacyUploadTempDirectory, entry.name); const stats = await plugins.fs.promises.lstat(path, { bigint: true }); if (stats.uid !== BigInt(currentUid())) continue; ownedMatches += 1; if (ownedMatches > maximumLegacyUploadRoots * 2) { throw new Error('The legacy controller upload temp directory has too many owned roots.'); } assertOwnedNode(path, stats, 'directory', true); if (tombstoneMatch) { if (tombstoneMatch[1] !== journalArg.nonce) { throw new Error(`Unexpected legacy controller upload tombstone: ${path}`); } const sourceName = entry.name.slice(0, entry.name.indexOf('.agl-v23-')); const sourcePath = plugins.path.join(this.legacyUploadTempDirectory, sourceName); if (tombstones.has(sourcePath)) { throw new Error(`Duplicate legacy controller upload tombstone: ${path}`); } tombstones.set(sourcePath, path); } else { sources.push(path); } } sources.sort(); return { sources, tombstones }; } private async assertNoLegacyUploadRoots(journalArg: IAGLHomeMigrationJournal): Promise { const inventory = await this.scanLegacyUploadRoots(journalArg); if (inventory.sources.length > 0 || inventory.tombstones.size > 0) { throw new Error('Legacy controller upload state appeared after cleanup completed.'); } } private async processLegacyUploadRootBinding( bindingArg: ILegacyUploadRootBinding, journalArg: IAGLHomeMigrationJournal, ): Promise { const tombstonePath = this.legacyUploadTombstonePath(bindingArg, journalArg); const [sourceStats, tombstoneStats] = await Promise.all([ lstatIfPresent(bindingArg.path), lstatIfPresent(tombstonePath), ]); if (sourceStats && tombstoneStats) { throw new Error(`Legacy controller upload source and tombstone both exist: ${bindingArg.path}`); } const expectedIdentity = { device: bindingArg.device, inode: bindingArg.inode }; if (sourceStats) { if (!identitiesEqual(identityFromStats(sourceStats), expectedIdentity)) { throw new Error(`Legacy controller upload root reappeared with a new identity: ${bindingArg.path}`); } assertOwnedNode(bindingArg.path, sourceStats, 'directory', true); await assertSafeLegacyUploadTree(bindingArg.path, bindingArg.device); await this.preflight(); await plugins.fs.promises.rename(bindingArg.path, tombstonePath); await syncDirectory(this.legacyUploadTempDirectory); } else if (tombstoneStats) { if (!identitiesEqual(identityFromStats(tombstoneStats), expectedIdentity)) { throw new Error(`Legacy controller upload tombstone identity changed: ${tombstonePath}`); } await syncDirectory(this.legacyUploadTempDirectory); } else { await syncDirectory(this.legacyUploadTempDirectory); return; } const movedStats = await plugins.fs.promises.lstat(tombstonePath, { bigint: true }); if (!identitiesEqual(identityFromStats(movedStats), expectedIdentity)) { throw new Error(`Legacy controller upload tombstone changed before cleanup: ${tombstonePath}`); } assertOwnedNode(tombstonePath, movedStats, 'directory', true); await assertSafeLegacyUploadTree(tombstonePath, bindingArg.device); await this.preflight(); await plugins.fs.promises.rm(tombstonePath, { recursive: true }); await syncDirectory(this.legacyUploadTempDirectory); } private async cleanupLegacyUploadRoots(journalArg: IAGLHomeMigrationJournal): Promise { if (journalArg.completedOperations.includes(legacyUploadCleanupOperation)) { await this.assertNoLegacyUploadRoots(journalArg); return; } for (let attempt = 0; attempt < 4; attempt++) { // Old upload roots have no owner marker. Repeat the complete writer and // socket preflight immediately before binding and deleting them. await this.preflight(); const inventory = await this.scanLegacyUploadRoots(journalArg); const bindingByPath = new Map( journalArg.legacyUploadRoots.map((bindingArg) => [bindingArg.path, bindingArg]), ); for (const sourcePath of inventory.sources) { const sourceStats = await plugins.fs.promises.lstat(sourcePath, { bigint: true }); const existing = bindingByPath.get(sourcePath); if (existing) { if (!identitiesEqual(identityFromStats(sourceStats), existing)) { throw new Error(`Legacy controller upload root reappeared with a new identity: ${sourcePath}`); } continue; } if (journalArg.legacyUploadRoots.length >= maximumLegacyUploadRoots) { throw new Error('The legacy controller upload root count exceeds its migration limit.'); } assertOwnedNode(sourcePath, sourceStats, 'directory', true); await assertSafeLegacyUploadTree(sourcePath, sourceStats.dev.toString(10)); const binding = { path: sourcePath, ...identityFromStats(sourceStats) }; journalArg.legacyUploadRoots.push(binding); bindingByPath.set(sourcePath, binding); } for (const sourcePath of inventory.tombstones.keys()) { if (!bindingByPath.has(sourcePath)) { throw new Error(`Unbound legacy controller upload tombstone: ${sourcePath}`); } } journalArg.legacyUploadRoots.sort((leftArg, rightArg) => ( leftArg.path < rightArg.path ? -1 : leftArg.path > rightArg.path ? 1 : 0 )); await this.writeJournal(journalArg); for (const binding of journalArg.legacyUploadRoots) { await this.processLegacyUploadRootBinding(binding, journalArg); } const remaining = await this.scanLegacyUploadRoots(journalArg); if (remaining.sources.length === 0 && remaining.tombstones.size === 0) { await this.markOperation(journalArg, legacyUploadCleanupOperation); return; } for (const sourcePath of remaining.sources) { const existing = bindingByPath.get(sourcePath); if (!existing) continue; const stats = await plugins.fs.promises.lstat(sourcePath, { bigint: true }); if (!identitiesEqual(identityFromStats(stats), existing)) { throw new Error(`Legacy controller upload root reappeared with a new identity: ${sourcePath}`); } } } throw new Error('Legacy controller upload roots changed repeatedly during migration.'); } private async moveDirect( sourceArg: string, destinationArg: string, operationArg: string, kindArg: 'file' | 'directory', journalArg: IAGLHomeMigrationJournal, ): Promise { const source = await lstatIfPresent(sourceArg); const destination = await lstatIfPresent(destinationArg); if (journalArg.completedOperations.includes(operationArg)) { if (source) { throw new Error(`Completed AGL migration source reappeared: ${sourceArg}`); } if (destination) { assertOwnedNode(destinationArg, destination, kindArg, kindArg === 'directory'); } return; } if (source && destination) { throw new Error(`AGL migration source and destination both exist: ${sourceArg}`); } if (!source && !destination) return; if (source) { assertOwnedNode(sourceArg, source, kindArg, kindArg === 'directory'); if (kindArg === 'directory') await assertSafeSourceTree(sourceArg); const parent = plugins.path.dirname(destinationArg); await plugins.fs.promises.mkdir(parent, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(parent, 0o700); await plugins.fs.promises.rename(sourceArg, destinationArg); await syncDirectory(plugins.path.dirname(sourceArg)); await syncDirectory(parent); } else { assertOwnedNode(destinationArg, destination!, kindArg, kindArg === 'directory'); } await this.markOperation(journalArg, operationArg); } private async markOperation( journalArg: IAGLHomeMigrationJournal, operationArg: string, ): Promise { if (!isKnownOperationName(operationArg)) { throw new Error(`Unknown AGL migration operation: ${operationArg}`); } if (journalArg.completedOperations.includes(operationArg)) return; journalArg.completedOperations.push(operationArg); journalArg.completedOperations.sort(); await this.writeJournal(journalArg); } private async cleanupLegacyRoots(): Promise { const credentialRoots = [ ...(this.options.legacyPaths.legacySourceEnabled ? [plugins.path.join(this.options.legacyPaths.activeDataRoot, 'flex-provider-credentials')] : []), ...(this.options.legacyPaths.legacyConfigRoot ? [plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'flex-provider-credentials')] : []), ]; for (const root of credentialRoots) await this.removeEmptyDirectory(root); if (!this.options.legacyPaths.development && this.options.legacyPaths.legacySourceEnabled) { await this.removeEmptyDirectory(this.options.legacyPaths.activeDataRoot); } if (this.options.legacyPaths.legacyConfigRoot) { await this.removeEmptyDirectory(this.options.legacyPaths.legacyConfigRoot); } if (this.options.legacyPaths.legacyStateRoot) { await this.removeEmptyDirectory(this.options.legacyPaths.legacyStateRoot); } } private async removeEmptyDirectory(pathArg: string): Promise { const stats = await lstatIfPresent(pathArg); if (!stats) return; assertOwnedNode(pathArg, stats, 'directory'); if ((await readBoundedDirectory(pathArg)).length !== 0) return; await plugins.fs.promises.rmdir(pathArg); await syncDirectory(plugins.path.dirname(pathArg)); } private async writeJournal(journalArg: IAGLHomeMigrationJournal): Promise { const serialized = `${JSON.stringify(journalArg)}\n`; if (Buffer.byteLength(serialized, 'utf8') > maximumJournalBytes) { throw new Error('AGL home migration journal exceeds its size limit.'); } const temporary = `${this.journalPath}.${process.pid}-${plugins.crypto.randomBytes(8).toString('hex')}.tmp`; let handle: plugins.fs.promises.FileHandle | undefined; let temporaryIdentity: IFilesystemIdentity | undefined; try { handle = await plugins.fs.promises.open( temporary, plugins.fs.constants.O_WRONLY | plugins.fs.constants.O_CREAT | plugins.fs.constants.O_EXCL | plugins.fs.constants.O_NOFOLLOW, 0o600, ); temporaryIdentity = identityFromStats(await handle.stat({ bigint: true })); await handle.chmod(0o600); await handle.writeFile(serialized, 'utf8'); await handle.sync(); await handle.close(); handle = undefined; await plugins.fs.promises.rename(temporary, this.journalPath); temporaryIdentity = undefined; await syncDirectory(this.options.paths.migration); const persisted = await readPrivateJournal(this.journalPath); if (!persisted || JSON.stringify(persisted) !== JSON.stringify(journalArg)) { throw new Error('AGL home migration journal was not persisted exactly.'); } } catch (errorArg) { const cleanupErrors: unknown[] = []; if (handle) { try { await handle.close(); } catch (cleanupErrorArg) { cleanupErrors.push(cleanupErrorArg); } } if (temporaryIdentity) { try { const stats = await lstatIfPresent(temporary); if (!stats || !identitiesEqual(identityFromStats(stats), temporaryIdentity)) { throw new Error('AGL journal temporary identity changed before cleanup.'); } await plugins.fs.promises.unlink(temporary); await syncDirectory(this.options.paths.migration); } catch (cleanupErrorArg) { cleanupErrors.push(cleanupErrorArg); } } if (cleanupErrors.length > 0) { throw new AggregateError( [errorArg, ...cleanupErrors], 'AGL journal write failed and cleanup was incomplete.', { cause: errorArg }, ); } throw errorArg; } } private async cleanupJournalTemporaryFiles(): Promise { const migrationStats = await lstatIfPresent(this.options.paths.migration); if (!migrationStats) return; const names = (await readBoundedDirectory(this.options.paths.migration)) .filter((name) => journalTemporaryPattern.test(name)); if (names.length > maximumJournalTemporaryFiles) { throw new Error('AGL migration directory contains too many journal artifacts.'); } for (const name of names) { const path = plugins.path.join(this.options.paths.migration, name); const stats = await plugins.fs.promises.lstat(path, { bigint: true }); if ( !stats.isFile() || stats.isSymbolicLink() || stats.uid !== BigInt(currentUid()) || stats.nlink !== 1n || Number(stats.mode & 0o777n) !== 0o600 || stats.size > BigInt(maximumJournalBytes) ) throw new Error(`AGL journal artifact is unsafe: ${path}`); const identity = identityFromStats(stats); const stable = await lstatIfPresent(path); if (!stable || !identitiesEqual(identityFromStats(stable), identity)) { throw new Error(`AGL journal artifact changed before cleanup: ${path}`); } await plugins.fs.promises.unlink(path); } if (names.length > 0) await syncDirectory(this.options.paths.migration); } private async unlinkExactFile( pathArg: string, identityArg: IFilesystemIdentity, ): Promise { const current = await lstatIfPresent(pathArg); if (!current) return; if (!identitiesEqual(identityFromStats(current), identityArg)) { throw new Error(`AGL migration file identity changed before removal: ${pathArg}`); } await plugins.fs.promises.unlink(pathArg); } private async cleanupStaleLockTemporaries(): Promise { const parent = plugins.path.dirname(this.lockPath); const base = plugins.path.basename(this.lockPath); const entries = await plugins.fs.promises.readdir(parent, { withFileTypes: true }); const temporaryNames = entries .map((entry) => entry.name) .filter((name) => name.startsWith(`${base}.tmp-`)); if (temporaryNames.length > maximumLockTemporaryFiles) { throw new Error('Too many AGL migration lock temporary artifacts exist.'); } for (const name of temporaryNames) { const match = lockTemporaryPattern.exec(name.slice(base.length)); if (!match) throw new Error(`Unknown AGL migration lock artifact: ${name}`); const uid = Number(match[1]); const pid = Number(match[2]); if (!Number.isSafeInteger(uid) || uid !== currentUid() || !Number.isSafeInteger(pid)) { throw new Error(`AGL migration lock temporary artifact binding is invalid: ${name}`); } const path = plugins.path.join(parent, name); const stats = await plugins.fs.promises.lstat(path, { bigint: true }); if ( !stats.isFile() || stats.isSymbolicLink() || stats.uid !== BigInt(currentUid()) || (stats.nlink !== 1n && stats.nlink !== 2n) || Number(stats.mode & 0o777n) !== 0o600 || stats.size > BigInt(maximumLockBytes) ) throw new Error(`AGL migration lock temporary artifact is unsafe: ${path}`); const identity = identityFromStats(stats); let owner: ILockOwner | undefined; try { owner = (await readPrivateLock(path, true))?.owner; } catch (errorArg) { const live = await readControllerProcessIdentity(pid); if (live) continue; } if (owner) { if (owner.uid !== uid || owner.pid !== pid || owner.nonce !== match[3]) { throw new Error(`AGL migration lock temporary artifact owner binding is invalid: ${path}`); } const live = await readControllerProcessIdentity(owner.pid); if (live && live.fingerprint === owner.fingerprint) continue; } else if (await readControllerProcessIdentity(pid)) continue; await this.unlinkExactFile(path, identity); } if (temporaryNames.length > 0) await syncDirectory(parent); } private async acquireLock(): Promise<{ identity: IFilesystemIdentity; owner: ILockOwner }> { const identity = await readControllerProcessIdentity(process.pid); if (!identity) throw new Error('AGL home migration process identity cannot be verified.'); await this.cleanupStaleLockTemporaries(); const owner: ILockOwner = { version: 1, pid: process.pid, uid: currentUid(), fingerprint: identity.fingerprint, nonce: plugins.crypto.randomBytes(32).toString('hex'), }; const temporaryPath = `${this.lockPath}.tmp-${owner.uid}-${owner.pid}-${owner.nonce}`; for (let attempt = 0; attempt < 2; attempt += 1) { let handle: plugins.fs.promises.FileHandle | undefined; let temporaryIdentity: IFilesystemIdentity | undefined; let publishedIdentity: IFilesystemIdentity | undefined; try { handle = await plugins.fs.promises.open( temporaryPath, plugins.fs.constants.O_WRONLY | plugins.fs.constants.O_CREAT | plugins.fs.constants.O_EXCL | plugins.fs.constants.O_NOFOLLOW, 0o600, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg; } if (handle) { try { temporaryIdentity = identityFromStats(await handle.stat({ bigint: true })); await handle.chmod(0o600); await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8'); await handle.sync(); await handle.close(); handle = undefined; const temporary = await readPrivateLock(temporaryPath); if (!temporary || !temporaryIdentity || !identitiesEqual(temporary.identity, temporaryIdentity)) { throw new Error('AGL migration lock temporary owner could not be confirmed.'); } try { await plugins.fs.promises.link(temporaryPath, this.lockPath); publishedIdentity = temporaryIdentity; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg; } if (publishedIdentity) { const publishedStats = await lstatIfPresent(this.lockPath); if (!publishedStats || !identitiesEqual(identityFromStats(publishedStats), publishedIdentity)) { throw new Error('AGL migration lock publication identity could not be confirmed.'); } await this.unlinkExactFile(temporaryPath, temporaryIdentity); await syncDirectory(plugins.path.dirname(this.lockPath)); const published = await readPrivateLock(this.lockPath); if ( !published || !identitiesEqual(published.identity, publishedIdentity) || JSON.stringify(published.owner) !== JSON.stringify(owner) ) throw new Error('AGL migration lock publication owner could not be confirmed.'); return { identity: published.identity, owner }; } await this.unlinkExactFile(temporaryPath, temporaryIdentity); await syncDirectory(plugins.path.dirname(this.lockPath)); } catch (errorArg) { const cleanupErrors: unknown[] = []; if (handle) { const failedHandle = handle; handle = undefined; try { await failedHandle.close(); } catch (cleanupErrorArg) { cleanupErrors.push(cleanupErrorArg); } } if (publishedIdentity) { try { await this.unlinkExactFile(this.lockPath, publishedIdentity); await syncDirectory(plugins.path.dirname(this.lockPath)); } catch (cleanupErrorArg) { cleanupErrors.push(cleanupErrorArg); } } if (temporaryIdentity) { try { await this.unlinkExactFile(temporaryPath, temporaryIdentity); await syncDirectory(plugins.path.dirname(this.lockPath)); } catch (cleanupErrorArg) { cleanupErrors.push(cleanupErrorArg); } } if (cleanupErrors.length > 0) { throw new AggregateError( [errorArg, ...cleanupErrors], 'AGL migration lock acquisition failed and cleanup was incomplete.', { cause: errorArg }, ); } throw errorArg; } finally { if (handle) await handle.close(); } } const existing = await readPrivateLock(this.lockPath); if (!existing) continue; const live = await readControllerProcessIdentity(existing.owner.pid); if (live && live.fingerprint === existing.owner.fingerprint) { throw new Error(`AGL home migration lock is owned by live PID ${existing.owner.pid}.`); } const stable = await readPrivateLock(this.lockPath); if (!stable || !identitiesEqual(stable.identity, existing.identity)) { throw new Error('AGL home migration lock changed during stale-owner inspection.'); } await this.unlinkExactFile(this.lockPath, existing.identity); await syncDirectory(plugins.path.dirname(this.lockPath)); } throw new Error('AGL home migration lock could not be acquired.'); } private async releaseLock(lockArg: { identity: IFilesystemIdentity; owner: ILockOwner; }): Promise { const current = await readPrivateLock(this.lockPath); if (!current || !identitiesEqual(current.identity, lockArg.identity)) { throw new Error('AGL home migration lock identity changed before release.'); } if (JSON.stringify(current.owner) !== JSON.stringify(lockArg.owner)) { throw new Error('AGL home migration lock owner changed before release.'); } await this.unlinkExactFile(this.lockPath, lockArg.identity); await syncDirectory(plugins.path.dirname(this.lockPath)); } } export interface IAGLHomeDataRootMigrationOptions extends IControllerDataDirectoryOptions { listDataWriterProcesses?: () => Promise; legacyUploadTempDirectory?: string; signal?: AbortSignal; } const resolveDataWriterProcessLister = ( optionsArg: IAGLHomeDataRootMigrationOptions, ): (() => Promise) => ( optionsArg.listDataWriterProcesses ?? (async () => { const cliPath = await plugins.fs.promises.realpath( plugins.url.fileURLToPath(new URL('../cli.js', import.meta.url)), ); return await listControllerDataWriterProcessesForCliPaths([cliPath], { includeInstalledPackagePaths: true, }); }) ); const createAGLHomeMigrationRunner = ( optionsArg: IAGLHomeDataRootMigrationOptions, listDataWriterProcessesArg: () => Promise, ): AGLHomeMigrationRunner => new AGLHomeMigrationRunner({ paths: resolveAGLHomePaths(optionsArg), legacyPaths: resolveAGLLegacyPaths(optionsArg), databaseConfig: readDatabaseConfig(optionsArg), invokerPid: process.pid, listDataWriterProcesses: listDataWriterProcessesArg, ...(optionsArg.legacyUploadTempDirectory ? { legacyUploadTempDirectory: optionsArg.legacyUploadTempDirectory } : {}), ...(optionsArg.signal ? { signal: optionsArg.signal } : {}), }); const createLegacyDataRootMigrationRunner = ( optionsArg: IAGLHomeDataRootMigrationOptions, listDataWriterProcessesArg: () => Promise, ): ControllerDataRootMigrationRunner => { const legacyPaths = resolveAGLLegacyPaths(optionsArg); const environment = optionsArg.environment ?? process.env; if (legacyPaths.development || !legacyPaths.legacyConfigRoot) { throw new Error('Legacy installed data-root migration is unavailable in development mode.'); } return new ControllerDataRootMigrationRunner({ oldRoot: legacyPaths.legacyConfigRoot, newRoot: legacyPaths.activeDataRoot, databaseConfig: readLegacyV2DatabaseConfig(optionsArg), oldEmbeddedSocketPath: legacyEmbeddedDatabaseSocketPath( plugins.path.join(legacyPaths.legacyConfigRoot, 'smartdb'), ), newEmbeddedSocketPath: legacyEmbeddedDatabaseSocketPath( plugins.path.join(legacyPaths.activeDataRoot, 'smartdb'), ), invokerPid: process.pid, listDataWriterProcesses: listDataWriterProcessesArg, preserveEmbeddedDataDirectory: Boolean(environment.HARNESS_CONTROLLER_DB_DIR?.trim()), ...(optionsArg.signal ? { signal: optionsArg.signal } : {}), }); }; export const preflightAGLHomeDataRootMigration = async ( optionsArg: IAGLHomeDataRootMigrationOptions = {}, ): Promise => { const listDataWriterProcesses = resolveDataWriterProcessLister(optionsArg); const homeMigration = createAGLHomeMigrationRunner(optionsArg, listDataWriterProcesses); await homeMigration.preflight({ allowDataWriters: true }); const legacyPaths = resolveAGLLegacyPaths(optionsArg); if (!await homeMigration.hasStarted() && !legacyPaths.development) { await createLegacyDataRootMigrationRunner(optionsArg, listDataWriterProcesses).preflight(); } }; export const ensureAGLHomeDataRoot = async ( optionsArg: IAGLHomeDataRootMigrationOptions = {}, ): Promise => { const paths = resolveAGLHomePaths(optionsArg); const legacyPaths = resolveAGLLegacyPaths(optionsArg); const databaseConfig = readDatabaseConfig(optionsArg); const listDataWriterProcesses = resolveDataWriterProcessLister(optionsArg); const homeMigration = createAGLHomeMigrationRunner(optionsArg, listDataWriterProcesses); if (await homeMigration.isCommitted()) { await homeMigration.preflight({ allowDataWriters: true }); return { directoryPath: paths.root, databaseConfig }; } if (await homeMigration.hasStarted()) return await homeMigration.run(); if (!legacyPaths.development) { await createLegacyDataRootMigrationRunner(optionsArg, listDataWriterProcesses).run(); } const result = await homeMigration.run(); return { directoryPath: paths.root, databaseConfig: result.databaseConfig ?? databaseConfig }; };