Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
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<ProductModelRef | null>;
|
|
|
|
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<string | null> {
|
|
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<AgentFileSnapshot[]> {
|
|
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<string, { prompt?: unknown; skillIds?: unknown }>();
|
|
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<CodingProjectAgentV2> {
|
|
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<void> {
|
|
const failures: string[] = [];
|
|
const restore = async (label: string, operation: () => Promise<void>) => {
|
|
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<CodingProjectMigrationResult> {
|
|
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),
|
|
};
|
|
}
|