feat: add coding project schema v2 migration

This commit is contained in:
2026-08-22 20:56:33 +08:00
parent 9a31dacb2a
commit 81d8ad1b6b
8 changed files with 1690 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
// @vitest-environment node
import {
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config';
import {
buildProjectAgentManifest,
normalizeProjectConfig,
} from '../../electron/opencode/project-config';
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
import {
acknowledgeLegacyConversationNotice,
readCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { migrateCodingProjectToV2 } from '../../electron/coding-projects/migration';
const scratchRoots: string[] = [];
const CREATED = '2026-08-20T00:00:00.000Z';
const MIGRATED = '2026-08-22T09:00:00.000Z';
const RETRIED = '2026-08-22T09:01:00.000Z';
const MODEL = {
accountId: 'account-a',
modelId: 'provider/model-a',
thinkingLevel: 'high' as const,
};
function makeAgent(overrides: Partial<ProjectAgentConfig>): ProjectAgentConfig {
return {
id: 'agent-a',
avatarId: 'avatar-01',
roleName: 'Implementer',
name: 'Agent A',
builtIn: false,
enabled: true,
model: 'legacy/model-a',
skillIds: ['tdd', 'game-engine'],
responsibility: {
mission: 'Implement',
owns: ['electron/coding-projects'],
boundaries: [],
collaborators: [],
principles: ['Preserve state'],
},
prompt: '\nOriginal prompt\n',
archivedAt: null,
pinned: true,
createdAt: CREATED,
updatedAt: CREATED,
...overrides,
};
}
function makeLegacyConfig(): ProjectConfig {
return {
schemaVersion: 1,
projectType: 'custom',
initialized: true,
defaultModel: null,
agents: [
makeAgent({ id: 'unique', name: 'Unique', model: 'legacy/unique' }),
makeAgent({
id: 'unresolved',
avatarId: 'avatar-02',
name: 'Unresolved',
model: 'legacy/ambiguous',
prompt: 'Keep unresolved prompt',
archivedAt: '2026-08-21T00:00:00.000Z',
}),
makeAgent({
id: 'no-account',
avatarId: 'avatar-03',
name: 'No Account',
model: null,
skillIds: [],
}),
],
knowledgeDirectory: 'knowledge',
createdAt: CREATED,
updatedAt: CREATED,
};
}
async function stageLegacyProject(options: { conversations?: boolean } = {}): Promise<{
projectPath: string;
config: ProjectConfig;
projectSource: string;
conversationSource: string | null;
}> {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-migration-v2-'));
scratchRoots.push(projectPath);
const config = makeLegacyConfig();
const projectFile = path.join(projectPath, '.niancode', 'project.json');
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
await Promise.all([
mkdir(path.dirname(projectFile), { recursive: true }),
mkdir(agentDirectory, { recursive: true }),
mkdir(path.join(projectPath, '.opencode', 'skills'), { recursive: true }),
]);
const projectSource = `${JSON.stringify(config, null, 2)}\n`;
await writeFile(projectFile, projectSource, 'utf8');
for (const entry of buildProjectAgentManifest(normalizeProjectConfig(config)).entries) {
await writeFile(path.join(projectPath, '.opencode', entry.relativePath), entry.content, 'utf8');
}
await writeFile(path.join(projectPath, '.opencode', 'skills', 'keep.md'), 'keep me', 'utf8');
let conversationSource: string | null = null;
if (options.conversations !== false) {
conversationSource = `${JSON.stringify({
schemaVersion: 1,
sessions: [{ id: 'old-opencode-session', agentId: 'unique', title: 'Legacy' }],
}, null, 2)}\n`;
await writeFile(path.join(projectPath, '.niancode', 'conversations.json'), conversationSource, 'utf8');
}
return { projectPath, config, projectSource, conversationSource };
}
afterEach(async () => {
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
})));
});
describe('coding project v1 to v2 migration', () => {
it('backs up v1, maps unique models, requires unresolved selection, and hides old sessions', async () => {
const staged = await stageLegacyProject();
const unresolvedFile = path.join(staged.projectPath, '.opencode', 'agent', 'unresolved.md');
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
await writeFile(unresolvedFile, 'locally modified Agent\n', 'utf8');
await writeFile(customFile, 'unknown Agent\n', 'utf8');
const resolveLegacyModel = vi.fn(async ({ legacyModel }: { legacyModel: string }) => (
legacyModel === 'legacy/unique' ? MODEL : null
));
const result = await migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel,
now: () => MIGRATED,
});
expect(result.status).toBe('migrated');
if (result.status !== 'migrated') throw new Error('Expected migrated result');
expect(result.config.agents).toEqual([
expect.objectContaining({
id: 'unique',
name: 'Unique',
prompt: '\nOriginal prompt\n',
skillIds: ['tdd', 'game-engine'],
archivedAt: null,
model: MODEL,
modelResolution: 'resolved',
}),
expect.objectContaining({
id: 'unresolved',
prompt: 'Keep unresolved prompt',
archivedAt: '2026-08-21T00:00:00.000Z',
model: null,
modelResolution: 'required',
}),
expect.objectContaining({
id: 'no-account',
model: null,
modelResolution: 'required',
}),
]);
expect(resolveLegacyModel).toHaveBeenCalledTimes(2);
expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']);
expect(result.backedUpUncertainAgents.sort()).toEqual(['custom.md', 'unresolved.md']);
expect(JSON.parse(await readFile(
path.join(staged.projectPath, '.niancode', 'conversations.json'),
'utf8',
))).toEqual({ schemaVersion: 2, conversations: [] });
expect(JSON.parse(await readFile(path.join(result.backupDirectory, 'project.json'), 'utf8')))
.toMatchObject({ schemaVersion: 1 });
expect(await readFile(path.join(result.backupDirectory, 'conversations.json'), 'utf8'))
.toBe(staged.conversationSource);
expect(await readFile(
path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'),
'utf8',
)).toBe('locally modified Agent\n');
expect(await readFile(
path.join(result.backupDirectory, '.opencode', 'agent', 'custom.md'),
'utf8',
)).toBe('unknown Agent\n');
await expect(readFile(unresolvedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8'))
.toBe('keep me');
const pending = await readCodingProjectConfigV2(staged.projectPath);
expect(pending).toMatchObject({ status: 'valid', config: { legacyConversationNotice: 'pending' } });
const acknowledged = await acknowledgeLegacyConversationNotice(staged.projectPath, { now: RETRIED });
expect(acknowledged.legacyConversationNotice).toBe('acknowledged');
});
it('does not overwrite v1 when the backup cannot be created', async () => {
const staged = await stageLegacyProject();
const writer = vi.fn(atomicWriteJson);
const blockedCopy = vi.fn(async () => {
throw new Error('backup unavailable');
}) as unknown as typeof copyFile;
await expect(migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async () => MODEL,
now: () => MIGRATED,
copyFile: blockedCopy,
writeJson: writer,
})).rejects.toThrow('backup unavailable');
expect(writer).not.toHaveBeenCalled();
expect(await readFile(path.join(staged.projectPath, '.niancode', 'project.json'), 'utf8'))
.toBe(staged.projectSource);
expect(await readFile(path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8'))
.toBe(staged.conversationSource);
});
it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => {
const staged = await stageLegacyProject();
let writeCount = 0;
const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => {
writeCount += 1;
if (writeCount === 2) {
await atomicWriteJson(filePath, { corrupt: true });
throw new Error('simulated project write failure');
}
await atomicWriteJson(filePath, value);
});
await expect(migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
now: () => MIGRATED,
writeJson: corruptingWriter,
})).rejects.toThrow('simulated project write failure');
expect(await readFile(path.join(staged.projectPath, '.niancode', 'project.json'), 'utf8'))
.toBe(staged.projectSource);
expect(await readFile(path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8'))
.toBe(staged.conversationSource);
for (const entry of buildProjectAgentManifest(normalizeProjectConfig(staged.config)).entries) {
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
.toBe(entry.content);
}
const retry = await migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
now: () => RETRIED,
});
expect(retry.status).toBe('migrated');
expect(JSON.parse(await readFile(
path.join(staged.projectPath, '.niancode', 'conversations.json'),
'utf8',
))).toEqual({ schemaVersion: 2, conversations: [] });
});
});

View File

@@ -0,0 +1,230 @@
// @vitest-environment node
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createCodingConversationStore,
validateSessionKey,
} from '../../electron/coding-projects/conversation-store';
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
import {
createCodingProjectAgent,
readCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
const scratchRoots: string[] = [];
const NOW = '2026-08-22T08:00:00.000Z';
const NEXT = '2026-08-22T08:00:01.000Z';
const MODEL = {
accountId: 'account-local',
modelId: 'provider/model-a',
thinkingLevel: 'medium' as const,
};
const RESPONSIBILITY = {
mission: 'Implement the assigned work',
owns: ['electron/coding-projects'],
boundaries: [],
collaborators: [],
principles: ['Keep metadata local'],
};
async function makeProjectPath(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-v2-'));
scratchRoots.push(root);
return root;
}
afterEach(async () => {
vi.clearAllMocks();
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
})));
});
describe('coding project schema v2', () => {
it('creates project, Agent, and empty Conversation metadata without a runtime child', async () => {
const projectPath = await makeProjectPath();
const storage = createMemoryCodingProjectStorage();
const store = createCodingProjectStore(storage, {
createId: () => 'project-stable-id',
now: () => NOW,
});
const projectStartedAt = performance.now();
const { project, config } = await createLocalCodingProject({ projectPath, now: NOW }, store);
const projectDurationMs = performance.now() - projectStartedAt;
const agentStartedAt = performance.now();
const agent = await createCodingProjectAgent(projectPath, {
id: 'implementer',
avatarId: 'avatar-01',
roleName: 'Implementer',
name: 'Implementation Agent',
model: MODEL,
modelResolution: 'resolved',
responsibility: RESPONSIBILITY,
prompt: '\nPreserve this prompt.\n',
skillIds: ['tdd'],
}, { now: NEXT });
const agentDurationMs = performance.now() - agentStartedAt;
const conversations = createCodingConversationStore(projectPath, {
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
now: () => NEXT,
});
const conversationStartedAt = performance.now();
const conversation = await conversations.create({
agentId: agent.id,
title: 'Local draft',
model: MODEL,
modelResolution: 'resolved',
});
const conversationDurationMs = performance.now() - conversationStartedAt;
expect(project.id).toBe('project-stable-id');
expect(config).toMatchObject({ schemaVersion: 2, agents: [] });
expect(agent).toMatchObject({
id: 'implementer',
name: 'Implementation Agent',
prompt: '\nPreserve this prompt.\n',
skillIds: ['tdd'],
archivedAt: null,
model: MODEL,
modelResolution: 'resolved',
});
expect(conversation).toMatchObject({
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
agentId: 'implementer',
model: MODEL,
modelResolution: 'resolved',
});
expect(conversation).not.toHaveProperty('piSessionId');
expect(conversation).not.toHaveProperty('sessionKey');
expect(vi.mocked(spawn)).not.toHaveBeenCalled();
expect(projectDurationMs).toBeLessThan(1_000);
expect(agentDurationMs).toBeLessThan(500);
expect(conversationDurationMs).toBeLessThan(500);
});
it('requires an explicit model selection when resolution is required', async () => {
const projectPath = await makeProjectPath();
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-id',
now: () => NOW,
});
await createLocalCodingProject({ projectPath, now: NOW }, store);
const agent = await createCodingProjectAgent(projectPath, {
id: 'unresolved',
avatarId: 'avatar-02',
roleName: 'Unresolved',
name: 'Unresolved Agent',
model: null,
modelResolution: 'required',
responsibility: RESPONSIBILITY,
}, { now: NEXT });
expect(agent).toMatchObject({ model: null, modelResolution: 'required' });
const read = await readCodingProjectConfigV2(projectPath);
expect(read.status).toBe('valid');
if (read.status === 'valid') {
expect(read.config.agents[0]).toMatchObject({ model: null, modelResolution: 'required' });
}
});
it('preserves one project id across concurrent first opens of the same folder', async () => {
const projectPath = await makeProjectPath();
let createCount = 0;
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => `project-${++createCount}`,
now: () => NOW,
});
const [first, second] = await Promise.all([
store.openFolder(projectPath),
store.openFolder(projectPath),
]);
expect(first.id).toBe('project-1');
expect(second.id).toBe('project-1');
expect(createCount).toBe(1);
expect(await store.listProjects()).toHaveLength(1);
});
});
describe('coding Conversation schema v2', () => {
it.each([
'../session',
'..\\session',
'/absolute/session',
'C:\\absolute\\session',
'nested/session',
'nested\\session',
' session-opaque ',
])('rejects a non-opaque session key: %s', (sessionKey) => {
expect(() => validateSessionKey(sessionKey)).toThrow('opaque relative key');
});
it('allows only one Pi session creation for concurrent first prompts', async () => {
const projectPath = await makeProjectPath();
const store = createCodingConversationStore(projectPath, {
createId: () => '2c1f4e52-4af8-4fce-a82e-18972ed71b47',
now: () => NOW,
});
const conversation = await store.create({
agentId: 'implementer',
title: 'First prompt',
model: MODEL,
modelResolution: 'resolved',
});
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const createBinding = vi.fn(async () => {
await gate;
return { piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' };
});
const first = store.ensureSessionBinding(conversation.id, createBinding);
const second = store.ensureSessionBinding(conversation.id, createBinding);
await vi.waitFor(() => expect(createBinding).toHaveBeenCalledTimes(1));
release();
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }),
expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }),
]);
expect(createBinding).toHaveBeenCalledTimes(1);
expect(JSON.parse(await readFile(path.join(projectPath, '.niancode', 'conversations.json'), 'utf8')))
.toMatchObject({
schemaVersion: 2,
conversations: [{ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }],
});
});
it('atomically replaces JSON and cleans its temporary file after a failed replace', async () => {
const projectPath = await makeProjectPath();
const metadataDirectory = path.join(projectPath, '.niancode');
const filePath = path.join(metadataDirectory, 'atomic.json');
await atomicWriteJson(filePath, { version: 1 });
await atomicWriteJson(filePath, { version: 2 });
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ version: 2 });
const directoryTarget = path.join(metadataDirectory, 'cannot-replace-directory');
await mkdir(directoryTarget);
await expect(atomicWriteJson(directoryTarget, { version: 3 })).rejects.toBeDefined();
expect((await readdir(metadataDirectory)).filter((name) => name.endsWith('.tmp'))).toEqual([]);
});
});