blob: 54ccccad34a905c712681b485c59522de82a84ce [file]
import { randomUUID } from 'node:crypto';
import { access, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
connectionEnabledModelIds,
OPENCODE_FREE_DEFAULT_ENABLED_MODELS,
OPENCODE_FREE_DEFAULT_MODEL,
PROVIDER_DEFAULTS,
providerSupportsModelDiscovery,
type LlmConnection,
} from '@maka/core';
import type {
ConnectionCatalogEntry,
ConnectionModelDiscoveryResult,
ConnectionTestSummary,
CredentialLocator,
RuntimePolicy,
} from '@maka/core';
import type { AppSettings } from '@maka/core';
import { createConnectionStore } from '@maka/storage/connection-store';
import { createFileCredentialStore } from '@maka/storage/credential-store';
import type { RuntimePolicyStoresWriter } from '@maka/storage/runtime-policy-stores';
import { createSettingsStore } from '@maka/storage/settings-store';
const JOURNAL_FILE = '.runtime-host-m5-migration.json';
const LEGACY_FILES = ['llm-connections.json', 'credentials.json', 'settings.json'] as const;
interface MigrationJournal {
readonly version: 1;
readonly state: 'importing';
}
export async function migrateLegacyRuntimePolicy(input: {
readonly workspaceRoot: string;
readonly legacyConfigurationRoot?: string;
readonly stores: RuntimePolicyStoresWriter;
}): Promise<void> {
const journalPath = join(input.workspaceRoot, JOURNAL_FILE);
const legacyRoot = input.legacyConfigurationRoot ?? input.workspaceRoot;
const journal = await readJournal(journalPath);
const [catalog, vault, policy] = await Promise.all([
input.stores.connectionCatalog.getSnapshot(),
input.stores.credentialVault.getSnapshot(),
input.stores.runtimePolicy.getSnapshot(),
]);
const establishedHostState =
catalog.revision !== 0 ||
catalog.connections.length !== 0 ||
vault.revision !== 0 ||
vault.entries.length !== 0 ||
policy.revision !== 0;
const legacySettingsPath = join(legacyRoot, 'settings.json');
const settings = (await fileExists(legacySettingsPath))
? await createSettingsStore(legacyRoot).get()
: undefined;
if (await isVersionOnePolicy(join(input.workspaceRoot, 'runtime-policy.json'))) {
await importSubagents(input.stores, settings?.subagents ?? { presets: [] });
}
if (!journal) {
if (establishedHostState) return;
if (!(await hasLegacyState(legacyRoot))) return;
await writeJournal(journalPath);
}
const connectionStore = createConnectionStore(legacyRoot);
const credentialStore = createFileCredentialStore(legacyRoot);
const legacyConnections = (await connectionStore.list()).map(normalizeLegacyConnection);
const imported = await importConnections(input.stores, legacyConnections);
await importConnectionCredentials(input.stores, credentialStore, legacyConnections, imported);
if (settings) {
await importRuntimePolicy(input.stores, settings);
await importSettingsCredentials(input.stores, settings);
}
await importConnectionEffects(input.stores, legacyConnections, imported);
await importDefaultTarget(
input.stores,
imported,
legacyConnections,
await connectionStore.getDefault(),
);
await rm(journalPath, { force: true });
}
async function importSubagents(
stores: RuntimePolicyStoresWriter,
subagents: RuntimePolicy['subagents'],
): Promise<void> {
const current = await stores.runtimePolicy.getSnapshot();
const result = await stores.runtimePolicy.mutate({
expectedRevision: current.revision,
operation: { kind: 'set_subagents', value: subagents },
});
if (result.kind !== 'committed') {
throw new Error('Legacy subagent migration lost its exclusive revision');
}
}
async function importConnections(
stores: RuntimePolicyStoresWriter,
legacyConnections: readonly LlmConnection[],
): Promise<Map<string, ConnectionCatalogEntry>> {
let catalog = await stores.connectionCatalog.getSnapshot();
for (const legacy of legacyConnections) {
if (catalog.connections.some(({ slug }) => slug === legacy.slug)) continue;
const result = await stores.connectionCatalog.create({
expectedCatalogRevision: catalog.revision,
connection: {
slug: legacy.slug,
name: legacy.name,
providerType: legacy.providerType,
...(legacy.baseUrl ? { baseUrl: legacy.baseUrl } : {}),
enabled: legacy.enabled,
enabledModelIds: connectionEnabledModelIds(legacy),
},
});
if (result.kind !== 'committed') {
throw new Error(`Legacy Connection migration failed: ${result.kind}`);
}
catalog = result.snapshot;
}
return new Map(catalog.connections.map((connection) => [connection.slug, connection]));
}
async function importConnectionCredentials(
stores: RuntimePolicyStoresWriter,
credentialStore: ReturnType<typeof createFileCredentialStore>,
legacyConnections: readonly LlmConnection[],
imported: ReadonlyMap<string, ConnectionCatalogEntry>,
): Promise<void> {
for (const legacy of legacyConnections) {
const connection = imported.get(legacy.slug);
if (!connection) throw new Error(`Imported Connection is missing: ${legacy.slug}`);
const authKind = PROVIDER_DEFAULTS[legacy.providerType].authKind;
const credentialKind: 'api_key' | 'oauth_token' | null =
authKind === 'oauth_token' ? 'oauth_token' : authKind === 'none' ? null : 'api_key';
if (!credentialKind) continue;
const secret = await credentialStore.getSecret(legacy.slug, credentialKind);
if (!secret) continue;
await setCredential(
stores,
{
scope: 'connection',
connectionId: connection.connectionId,
kind: credentialKind,
},
secret,
'migration',
);
}
}
async function importConnectionEffects(
stores: RuntimePolicyStoresWriter,
legacyConnections: readonly LlmConnection[],
imported: ReadonlyMap<string, ConnectionCatalogEntry>,
): Promise<void> {
for (const legacy of legacyConnections) {
const connection = imported.get(legacy.slug);
if (!connection) continue;
if (!(await canImportConnectionEffects(stores, legacy, connection))) continue;
const modelResult = legacyModelResult(legacy);
if (modelResult) {
const prepared = await stores.operations.beginModelFetch(connection.connectionId);
if (prepared.kind !== 'ready') {
throw new Error(`Legacy Connection model inventory migration failed: ${prepared.kind}`);
}
const completed = await stores.operations.completeModelFetch(prepared.ticket, modelResult);
if (completed.kind !== 'committed') {
throw new Error('Legacy Connection model inventory was superseded during migration');
}
}
const test = legacyTestSummary(legacy);
if (!test) continue;
const prepared = await stores.operations.beginConnectionTest(
connection.connectionId,
legacy.defaultModel || null,
);
if (prepared.kind !== 'ready') {
throw new Error(`Legacy Connection health migration failed: ${prepared.kind}`);
}
const completed = await stores.operations.completeConnectionTest(prepared.ticket, test);
if (completed.kind !== 'committed') {
throw new Error('Legacy Connection health was superseded during migration');
}
}
}
async function canImportConnectionEffects(
stores: RuntimePolicyStoresWriter,
legacy: LlmConnection,
connection: ConnectionCatalogEntry,
): Promise<boolean> {
if (!providerSupportsModelDiscovery(legacy.providerType)) return false;
const authKind = PROVIDER_DEFAULTS[legacy.providerType].authKind;
if (authKind === 'none') return true;
const result = await stores.credentialVault.getStatus({
scope: 'connection',
connectionId: connection.connectionId,
kind: authKind === 'oauth_token' ? 'oauth_token' : 'api_key',
});
return result.kind === 'status' && result.status.configured;
}
async function importDefaultTarget(
stores: RuntimePolicyStoresWriter,
imported: ReadonlyMap<string, ConnectionCatalogEntry>,
legacyConnections: readonly LlmConnection[],
defaultSlug: string | null,
): Promise<void> {
if (!defaultSlug) return;
const connection = imported.get(defaultSlug);
const legacy = legacyConnections.find(({ slug }) => slug === defaultSlug);
const modelId = legacy?.defaultModel || connection?.enabledModelIds[0];
if (!connection || !modelId || !connection.enabledModelIds.includes(modelId)) return;
const catalog = await stores.connectionCatalog.getSnapshot();
const result = await stores.connectionCatalog.setDefaultTarget({
expectedCatalogRevision: catalog.revision,
target: { connectionId: connection.connectionId, modelId },
});
if (result.kind !== 'committed') {
throw new Error(`Legacy default Connection migration failed: ${result.kind}`);
}
}
async function importRuntimePolicy(
stores: RuntimePolicyStoresWriter,
settings: AppSettings,
): Promise<void> {
const proxy = settings.network.proxy;
const values: RuntimePolicy = {
networkProxy: {
enabled: proxy.enabled,
protocol: proxy.protocol,
host: proxy.host,
port: proxy.port,
authEnabled: proxy.authEnabled,
username: proxy.username,
bypassList: [...proxy.bypassList],
autoBypassDomains: [...proxy.autoBypassDomains],
},
personalization: {
displayName: settings.personalization.displayName,
assistantTone: settings.personalization.assistantTone,
},
memory: settings.localMemory,
workspaceInstructions: settings.workspaceInstructions,
privacy: settings.privacy,
chatDefaults: settings.chatDefaults,
webSearch: {
enabled: settings.webSearch.enabled,
defaultProvider: settings.webSearch.defaultProvider,
},
subagents: settings.subagents,
};
const operations = [
{ kind: 'set_network_proxy' as const, value: values.networkProxy },
{ kind: 'set_personalization' as const, value: values.personalization },
{ kind: 'set_memory' as const, value: values.memory },
{ kind: 'set_workspace_instructions' as const, value: values.workspaceInstructions },
{ kind: 'set_privacy' as const, value: values.privacy },
{ kind: 'set_chat_defaults' as const, value: values.chatDefaults },
{ kind: 'set_web_search' as const, value: values.webSearch },
{ kind: 'set_subagents' as const, value: values.subagents },
];
for (const operation of operations) {
const current = await stores.runtimePolicy.getSnapshot();
const result = await stores.runtimePolicy.mutate({
expectedRevision: current.revision,
operation,
});
if (result.kind !== 'committed') {
throw new Error('Legacy Runtime policy migration lost its exclusive revision');
}
}
}
async function importSettingsCredentials(
stores: RuntimePolicyStoresWriter,
settings: AppSettings,
): Promise<void> {
const proxyPassword = settings.network.proxy.password;
if (proxyPassword) {
await setCredential(stores, { scope: 'network_proxy', kind: 'password' }, proxyPassword);
}
const tavilyApiKey = settings.webSearch.providers.tavily.apiKey;
if (tavilyApiKey) {
await setCredential(
stores,
{ scope: 'web_search', provider: 'tavily', kind: 'api_key' },
tavilyApiKey,
);
}
}
async function setCredential(
stores: RuntimePolicyStoresWriter,
locator: CredentialLocator,
secret: string,
authority: 'client' | 'migration' = 'client',
): Promise<void> {
const current = await stores.credentialVault.getStatus(locator);
if (current.kind === 'connection_not_found') {
throw new Error('Legacy credential refers to a missing Connection');
}
if (current.status.configured) return;
const input = { locator, expected: null, secret };
const result =
authority === 'migration'
? await stores.operations.importConnectionCredential(input)
: await stores.credentialVault.set(input);
if (result.kind !== 'committed') {
throw new Error(`Legacy credential migration failed: ${result.kind}`);
}
}
function legacyModelResult(connection: LlmConnection): ConnectionModelDiscoveryResult | null {
if (
connection.models?.length &&
connection.modelSource &&
connection.modelsFetchedAt !== undefined
) {
return {
models: connection.models,
source: connection.modelSource,
fetchedAt: connection.modelsFetchedAt,
};
}
const enabledModelIds = connectionEnabledModelIds(connection);
return enabledModelIds.length > 0
? {
models: enabledModelIds.map((id) => ({ id })),
source: 'fallback',
fetchedAt: connection.updatedAt,
}
: null;
}
function legacyTestSummary(connection: LlmConnection): ConnectionTestSummary | null {
if (!connection.lastTestStatus || !connection.lastTestAt) return null;
return {
status: connection.lastTestStatus,
checkedAt: connection.lastTestAt,
...(connection.lastTestStatus === 'needs_reauth'
? { errorClass: 'auth' as const }
: connection.lastTestStatus === 'error'
? { errorClass: 'unknown' as const }
: {}),
};
}
function normalizeLegacyConnection(connection: LlmConnection): LlmConnection {
const isBootstrapBase =
connection.slug === 'opencode-free' &&
connection.name === 'OpenCode Free' &&
connection.providerType === 'opencode-free' &&
connection.baseUrl === undefined &&
connection.enabled === true &&
connection.models === undefined;
if (!isBootstrapBase) return connection;
const legacyV1 =
connection.defaultModel === 'big-pickle' &&
sameStringList(connection.enabledModelIds, ['big-pickle']) &&
connection.extras === undefined;
const legacyV2 =
connection.defaultModel === OPENCODE_FREE_DEFAULT_MODEL &&
sameStringList(connection.enabledModelIds, [OPENCODE_FREE_DEFAULT_MODEL]) &&
bootstrapVersion(connection) === 2;
if (!legacyV1 && !legacyV2) return connection;
return {
...connection,
defaultModel: OPENCODE_FREE_DEFAULT_MODEL,
enabledModelIds: [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS],
};
}
function bootstrapVersion(connection: LlmConnection): number | undefined {
const extras = connection.extras;
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return undefined;
if (Object.keys(extras).length !== 1) return undefined;
const bootstrap = extras.makaBootstrap;
if (!bootstrap || typeof bootstrap !== 'object' || Array.isArray(bootstrap)) return undefined;
const record = bootstrap as { id?: unknown; version?: unknown };
if (Object.keys(record).length !== 2 || record.id !== 'opencode-free') return undefined;
return typeof record.version === 'number' ? record.version : undefined;
}
function sameStringList(
actual: readonly string[] | undefined,
expected: readonly string[],
): boolean {
return actual?.length === expected.length && actual.every((id, index) => id === expected[index]);
}
async function hasLegacyState(workspaceRoot: string): Promise<boolean> {
const results = await Promise.all(
LEGACY_FILES.map((file) => fileExists(join(workspaceRoot, file))),
);
return results.some(Boolean);
}
async function readJournal(path: string): Promise<MigrationJournal | null> {
let contents: string;
try {
contents = await readFile(path, 'utf8');
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') return null;
throw error;
}
const value = JSON.parse(contents) as Partial<MigrationJournal>;
if (value.version !== 1 || value.state !== 'importing') {
throw new Error('Invalid Runtime Host M5 migration journal');
}
return { version: 1, state: 'importing' };
}
async function writeJournal(path: string): Promise<void> {
const temporaryPath = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify({ version: 1, state: 'importing' } satisfies MigrationJournal)}\n`,
{ encoding: 'utf8', flag: 'wx' },
);
await rename(temporaryPath, path);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') return false;
throw error;
}
}
async function isVersionOnePolicy(path: string): Promise<boolean> {
try {
const value = JSON.parse(await readFile(path, 'utf8')) as { schemaVersion?: unknown };
return value.schemaVersion === 1;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}