Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
// @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 {
|
|
buildLegacyProjectAgentManifest,
|
|
normalizeLegacyProjectConfigV1,
|
|
} from '../../electron/coding-projects/legacy-v1';
|
|
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
|
|
import {
|
|
acknowledgeLegacyConversationNotice,
|
|
createCodingProjectConfigV2,
|
|
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 buildLegacyProjectAgentManifest(normalizeLegacyProjectConfigV1(config))) {
|
|
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('leaves legacy directories inert once project v2 is valid', async () => {
|
|
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-current-v2-'));
|
|
scratchRoots.push(projectPath);
|
|
const projectFile = path.join(projectPath, '.niancode', 'project.json');
|
|
const legacyFile = path.join(projectPath, '.opencode', 'agent', 'legacy.md');
|
|
await Promise.all([
|
|
mkdir(path.dirname(projectFile), { recursive: true }),
|
|
mkdir(path.dirname(legacyFile), { recursive: true }),
|
|
]);
|
|
await writeFile(projectFile, JSON.stringify(
|
|
createCodingProjectConfigV2(CREATED, 'custom'),
|
|
null,
|
|
2,
|
|
));
|
|
await writeFile(legacyFile, 'must remain untouched\n', 'utf8');
|
|
const resolveLegacyModel = vi.fn(async () => MODEL);
|
|
const copy = vi.fn(copyFile);
|
|
|
|
const result = await migrateCodingProjectToV2(projectPath, {
|
|
resolveLegacyModel,
|
|
copyFile: copy,
|
|
});
|
|
|
|
expect(result.status).toBe('already-current');
|
|
expect(resolveLegacyModel).not.toHaveBeenCalled();
|
|
expect(copy).not.toHaveBeenCalled();
|
|
expect(await readFile(legacyFile, 'utf8')).toBe('must remain untouched\n');
|
|
});
|
|
|
|
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).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();
|
|
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
|
|
await writeFile(customFile, 'unknown Agent\n', 'utf8');
|
|
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 buildLegacyProjectAgentManifest(normalizeLegacyProjectConfigV1(staged.config))) {
|
|
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
|
|
.toBe(entry.content);
|
|
}
|
|
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
|
|
|
|
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: [] });
|
|
expect(retry.backedUpUncertainAgents).toEqual(['custom.md']);
|
|
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
expect(await readFile(
|
|
path.join(retry.backupDirectory, '.opencode', 'agent', 'custom.md'),
|
|
'utf8',
|
|
)).toBe('unknown Agent\n');
|
|
});
|
|
});
|