import { copyFile, mkdir, readFile, rm, unlink, } from 'node:fs/promises'; import path from 'node:path'; import type { ProductModelRef } from '../coding-runtime/contracts'; import { buildLegacyProjectAgentManifest, normalizeLegacyProjectConfigV1, } from './legacy-v1'; import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config'; import { atomicWriteJson, atomicWriteText, type JsonFileWriter, } from './atomic-json'; import { CODING_PROJECT_CONFIG_PATH, normalizeCodingProjectConfigV2, type CodingProjectAgentV2, type CodingProjectConfigV2, } from './project-config'; import { CODING_CONVERSATIONS_PATH, createEmptyConversationFileV2, } from './conversation-store'; export interface LegacyModelResolutionInput { agentId: string; legacyModel: string; } export type LegacyModelResolver = ( input: LegacyModelResolutionInput, ) => Promise; export interface CodingProjectMigrationDependencies { resolveLegacyModel: LegacyModelResolver; now?: () => string; writeJson?: JsonFileWriter; copyFile?: typeof copyFile; } export type CodingProjectMigrationResult = | { status: 'already-current'; config: CodingProjectConfigV2; } | { status: 'migrated'; config: CodingProjectConfigV2; backupDirectory: string; removedGeneratedAgents: string[]; backedUpUncertainAgents: string[]; }; type AgentFileSnapshot = { fileName: string; filePath: string; content: string; generated: boolean; backupPath?: string; }; function isMissing(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT'; } function timestampKey(value: string): string { return value.replace(/[^0-9A-Za-z_-]/g, '-'); } async function readOptionalText(filePath: string): Promise { try { return await readFile(filePath, 'utf8'); } catch (error) { if (isMissing(error)) return null; throw error; } } async function snapshotLegacyAgentFiles( projectPath: string, config: ProjectConfig, backupDirectory: string, copy: typeof copyFile, ): Promise { const agentDirectory = path.join(projectPath, '.opencode', 'agent'); const expected = new Map(buildLegacyProjectAgentManifest(config).map((entry) => [ path.basename(entry.relativePath), entry.content, ])); const snapshots: AgentFileSnapshot[] = []; for (const [fileName, expectedContent] of expected) { const filePath = path.join(agentDirectory, fileName); const content = await readOptionalText(filePath); if (content === null) continue; const generated = expectedContent === content; if (generated) { snapshots.push({ fileName, filePath, content, generated }); continue; } const backupPath = path.join(backupDirectory, '.opencode', 'agent', fileName); await mkdir(path.dirname(backupPath), { recursive: true }); await copy(filePath, backupPath); snapshots.push({ fileName, filePath, content, generated, backupPath }); } return snapshots; } function legacyModelForAgent(config: ProjectConfig, agent: ProjectAgentConfig): string | null { const direct = typeof agent.model === 'string' ? agent.model.trim() : ''; const fallback = typeof config.defaultModel === 'string' ? config.defaultModel.trim() : ''; return direct || fallback || null; } function preserveLegacyAgentFields( rawProject: unknown, normalized: ProjectConfig, ): ProjectConfig { if (!rawProject || typeof rawProject !== 'object' || Array.isArray(rawProject)) return normalized; const rawAgents = (rawProject as { agents?: unknown }).agents; if (!Array.isArray(rawAgents)) return normalized; const rawById = new Map(); for (const value of rawAgents) { if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const record = value as { id?: unknown; prompt?: unknown; skillIds?: unknown }; const id = typeof record.id === 'string' ? record.id.trim() : ''; if (id) rawById.set(id, record); } return { ...normalized, agents: normalized.agents.map((agent) => { const raw = rawById.get(agent.id); const rawSkillIds = Array.isArray(raw?.skillIds) ? [...new Set(raw.skillIds .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean))] : agent.skillIds; return { ...agent, prompt: typeof raw?.prompt === 'string' ? raw.prompt : agent.prompt, skillIds: rawSkillIds, }; }), }; } async function migrateAgent( config: ProjectConfig, agent: ProjectAgentConfig, resolveLegacyModel: LegacyModelResolver, fallbackTimestamp: string, ): Promise { const legacyModel = legacyModelForAgent(config, agent); const model = legacyModel ? await resolveLegacyModel({ agentId: agent.id, legacyModel }) : null; return { id: agent.id, avatarId: agent.avatarId, ...(agent.avatarDataUrl ? { avatarDataUrl: agent.avatarDataUrl } : {}), roleName: agent.roleName, name: agent.name, builtIn: agent.builtIn, enabled: agent.enabled, model, modelResolution: model ? 'resolved' : 'required', skillIds: [...agent.skillIds], responsibility: { mission: agent.responsibility.mission, owns: [...agent.responsibility.owns], boundaries: [...agent.responsibility.boundaries], collaborators: [...agent.responsibility.collaborators], principles: [...agent.responsibility.principles], }, prompt: agent.prompt, archivedAt: agent.archivedAt ?? null, pinned: agent.pinned === true, createdAt: agent.createdAt ?? fallbackTimestamp, updatedAt: agent.updatedAt ?? fallbackTimestamp, }; } async function restoreOriginalState( projectConfigPath: string, projectSource: string, conversationsPath: string, conversationsSource: string | null, agentFiles: AgentFileSnapshot[], ): Promise { const failures: string[] = []; const restore = async (label: string, operation: () => Promise) => { try { await operation(); } catch (error) { failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); } }; await restore('project config', () => atomicWriteText(projectConfigPath, projectSource)); if (conversationsSource === null) { await restore('Conversation metadata', () => rm(conversationsPath, { force: true })); } else { await restore('Conversation metadata', () => atomicWriteText(conversationsPath, conversationsSource)); } for (const agent of agentFiles) { await restore(`Agent ${agent.fileName}`, () => atomicWriteText(agent.filePath, agent.content)); } if (failures.length > 0) { throw new Error(`Pi cutover rollback was incomplete: ${failures.join('; ')}`); } } export async function migrateCodingProjectToV2( projectPath: string, dependencies: CodingProjectMigrationDependencies, ): Promise { const projectConfigPath = path.join(projectPath, CODING_PROJECT_CONFIG_PATH); const conversationsPath = path.join(projectPath, CODING_CONVERSATIONS_PATH); const projectSource = await readFile(projectConfigPath, 'utf8'); const rawProject = JSON.parse(projectSource) as unknown; if (rawProject && typeof rawProject === 'object' && !Array.isArray(rawProject) && (rawProject as { schemaVersion?: unknown }).schemaVersion === 2) { return { status: 'already-current', config: normalizeCodingProjectConfigV2(rawProject), }; } const normalizedLegacyConfig = normalizeLegacyProjectConfigV1(rawProject); const legacyConfig = preserveLegacyAgentFields(rawProject, normalizedLegacyConfig); const conversationsSource = await readOptionalText(conversationsPath); const migrationTimestamp = dependencies.now?.() ?? new Date().toISOString(); const backupDirectory = path.join( projectPath, '.niancode', 'migration-backups', `pi-cutover-${timestampKey(migrationTimestamp)}`, ); const copy = dependencies.copyFile ?? copyFile; const writeJson = dependencies.writeJson ?? atomicWriteJson; await mkdir(path.dirname(backupDirectory), { recursive: true }); await mkdir(backupDirectory, { recursive: false }); await copy(projectConfigPath, path.join(backupDirectory, 'project.json')); if (conversationsSource !== null) { await copy(conversationsPath, path.join(backupDirectory, 'conversations.json')); } const agentFiles = await snapshotLegacyAgentFiles( projectPath, normalizedLegacyConfig, backupDirectory, copy, ); const agents = await Promise.all(legacyConfig.agents.map((agent) => migrateAgent( legacyConfig, agent, dependencies.resolveLegacyModel, migrationTimestamp, ))); const migratedConfig = normalizeCodingProjectConfigV2({ schemaVersion: 2, projectType: legacyConfig.projectType, initialized: legacyConfig.initialized, agents, knowledgeDirectory: 'knowledge', legacyConversationNotice: 'pending', createdAt: legacyConfig.createdAt, updatedAt: migrationTimestamp, }); try { await writeJson(conversationsPath, createEmptyConversationFileV2()); await writeJson(projectConfigPath, migratedConfig); for (const agent of agentFiles) await unlink(agent.filePath); } catch (error) { try { await restoreOriginalState( projectConfigPath, projectSource, conversationsPath, conversationsSource, agentFiles, ); } catch (rollbackError) { const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); const migrationMessage = error instanceof Error ? error.message : String(error); throw new Error( `Pi project migration failed (${migrationMessage}) and rollback was incomplete: ${rollbackMessage}`, { cause: rollbackError }, ); } throw error; } return { status: 'migrated', config: migratedConfig, backupDirectory, removedGeneratedAgents: agentFiles.filter((agent) => agent.generated).map((agent) => agent.fileName), backedUpUncertainAgents: agentFiles.filter((agent) => !agent.generated).map((agent) => agent.fileName), }; }