feat: add coding project schema v2 migration
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# Task: Implement PI-020 project and conversation schema v2
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260822-pi-schema-v2-5e4b7a91
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260822-pi-schema-v2-5e4b7a91-pi-schema-v2
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-schema-v2-5e4b7a91
|
||||
- Base commit: 9a31dacb2aad92a2deee0cec11fdb162a2916bfc
|
||||
- Owner: codex
|
||||
- Status: Completed
|
||||
|
||||
## Scope
|
||||
|
||||
- Implement `PI-020 — Project and Conversation schema v2 migration` from the merged Pi hard-cutover Spec/ticket graph.
|
||||
- Add vendor-neutral project configuration, project registry, Conversation metadata store, atomic JSON persistence, and one-time OpenCode cutover migration under `electron/coding-projects`.
|
||||
- Preserve stable Agent metadata while mapping legacy string models only through an injected unique resolver; unresolved/no-account models remain explicit user-selection requirements.
|
||||
- Back up schema v1 metadata before replacement, initialize empty schema v2 Conversations, expose a persistent one-time notice state, and clean only proven generated `.opencode/agent` files while moving uncertain content to the migration backup.
|
||||
- Add focused table, rollback, path-validation, single-flight, local-only creation, and performance tests without wiring the new stores into Renderer, Host routes, Provider Service, or Pi workers yet.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Keep project/Agent/Conversation identity product-owned. Conversation IDs are Makelore UUIDs; Pi session IDs and opaque session keys remain optional Main-private binding fields.
|
||||
- Reuse the vendor-neutral `ProductModelRef` and thinking-level contract from PI-010. Do not duplicate Pi/OpenCode provider identifiers in persisted schema v2.
|
||||
- A legacy model string maps automatically only when the injected resolver returns one unique account/model reference. Missing, ambiguous, or unavailable account mappings persist `{ model: null, modelResolution: "required" }`; no default model fallback is allowed.
|
||||
- Treat migration as one explicit v1→v2 branch, not a generic framework: create a timestamped backup first, use atomic per-file replacement, and restore original metadata/Agent files if any later migration operation fails.
|
||||
- Never recursively delete `userData/opencode` or arbitrary project `.opencode` content. Only exact current generated Agent materializations may be deleted; modified or unknown Agent files move to backup, and other `.opencode` paths remain untouched and unread by new code.
|
||||
- Project, partner, and empty Conversation creation are local metadata operations and must not import or call child-process/runtime code. Per-Conversation Pi binding creation exposes a single-flight contract but does not start Pi in this ticket.
|
||||
- Preserve the explicit real-Provider Accepted Risk and deferred macOS decisions; neither affects this local metadata ticket or becomes a Pass here.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- `maintain-project-docs` created and verified isolated feature ownership at base `9a31dacb2aad92a2deee0cec11fdb162a2916bfc`; Concurrent Task Gate and Planning Gate passed.
|
||||
- The planner audited PI-030 as Done in commit `fc7b80793c0a8b0a39d8eef31b57ee42895dd9fd`, recomputed the unchanged 18-node/31-edge DAG, and identified PI-020 as the unique Ready Frontier ticket from implementation base `9a31dac`.
|
||||
- The planner status commit remains read-only on its own branch because the repository Integration Gate is currently held by blocked task `20260821-integrate-model-switch-fix-8f2d6c41`; this does not block isolated PI-020 feature work.
|
||||
- Repository guidance, shared project memory, PI Spec sections 5/6/15/17/19/20, the PI-020 ticket, PI-010 contracts, existing v1 project/conversation modules, tests, and all planning peer task records were inspected before planning.
|
||||
- Existing v1 ownership is split between `shared/project-config.ts`, `shared/project-conversations.ts`, and `electron/opencode/*`; those remain the old product path until later cutover tickets. PI-020 introduces the new `electron/coding-projects` boundary rather than incidentally rewriting current OpenCode routes.
|
||||
- Other planning peers do not declare writes to the PI-020 files. `20260820-partner-create-model-parity-c83d` has an undefined scope and is retained as an unknown coordination warning; no confirmed contradictory decision or file ownership was found.
|
||||
- `codebase-design` keeps persistence/migration details behind small store interfaces. The repository prohibition on subagents is honored; no parallel agent was created.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Implement schema v2 types/normalizers and atomic JSON persistence, reusing PI-010 product model contracts without runtime imports beyond types.
|
||||
2. Implement local project registry/config/Agent creation and Conversation CRUD with UUID identity, opaque session-key validation, serialized writes, and per-Conversation binding single-flight.
|
||||
3. Implement the explicit v1 cutover: complete backup, injected unique model resolution, v2 atomic writes, one-time notice, generated/modified Agent classification, and rollback on failure.
|
||||
4. Add migration-table, atomic-failure/retry, Agent-file, path, single-flight, no-spawn, and local performance tests.
|
||||
5. Run focused tests, typecheck, lint, full tests, build when warranted, documentation gates, commit, and planner handoff.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added the vendor-neutral `electron/coding-projects` boundary with atomic JSON replacement, strict project schema v2, product-owned project registry, and Conversation schema v2. Agent and Conversation model state uses the PI-010 `ProductModelRef`; unresolved/no-account state is persisted as `model: null` plus `modelResolution: "required"` with no default fallback.
|
||||
- Added local-only project, Agent, and unbound Conversation creation. Project path upserts and Conversation writes are serialized; concurrent first prompts share one per-Conversation binding flight and persist one validated Pi session ID/opaque session key pair.
|
||||
- Added explicit v1→v2 migration with timestamped `.niancode/migration-backups/opencode-cutover-*` backup, injected unique legacy-model resolution, empty v2 Conversation initialization, and persistent `pending`→`acknowledged` legacy notice state.
|
||||
- Migration preserves stable Agent ID/name/prompt/skills/archive. Exact cleanup proof uses the old runtime's normalized `buildProjectAgentManifest` output; locally modified or unknown `.opencode/agent` files are copied into the migration backup before being removed, while unrelated `.opencode` content remains untouched.
|
||||
- A missing backup aborts before either v2 write. A failure after writes begin restores project metadata, Conversation metadata, and Agent file contents so the original v1 project remains retryable; a retry matrix test completes successfully after a deliberately corrupt Conversation write.
|
||||
- The real Provider gate remains explicitly waived: real protocol/account compatibility, Provider concurrency, credential isolation, and Provider-specific behavior are Accepted Risk and remain unverified, not Pass. No real Provider or macOS validation was performed in this ticket.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm install --frozen-lockfile`: passed with repository-pinned pnpm 10.33.4.
|
||||
- Focused schema/migration suite: 2 files, 15 tests passed. Coverage includes unique/unresolved/no-account mapping, stable Agent fields, modified/unknown generated files, no-backup refusal, corrupt write rollback/retry, empty v2 Conversations, one-time notice consumption, traversal/absolute session-key rejection, per-Conversation single-flight, atomic replacement/temp cleanup, local metadata performance budgets, and spawn spy = 0.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- Scoped ESLint over `electron/coding-projects` and both focused tests: passed with no findings.
|
||||
- `pnpm run lint:check`: passed with 0 errors and 6 pre-existing warnings outside this task's files.
|
||||
- `pnpm test`: 184 files, 2133 tests passed.
|
||||
- `pnpm run build:vite`: passed for Renderer, Main, Preload, and release utility output; existing dynamic-import and chunk-size warnings remain unchanged.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- `PI-040` may consume project/Agent `ProductModelRef` data after integration; it owns Provider catalog, credential projection, and managed prompt/skill resources. The accepted real-Provider risks must remain labeled unverified rather than being inferred from these local metadata tests.
|
||||
- `PI-050` may connect `ensureSessionBinding` to the Pi worker pool/session registry. Its runtime integration must retain the store's single-flight semantics and keep the opaque key relative/private.
|
||||
- `PI-100` may expose project/Agent/Conversation create routes. These routes must stay local-only until a prompt explicitly requests runtime preparation.
|
||||
- `PI-140` owns final cutover wiring and old OpenCode removal. It should consume the one-time legacy notice and must not broaden cleanup beyond the conservative migration behavior delivered here.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: Pi cutover migration implementation guidance in the architecture/planner task.
|
||||
Proposal: preserve v1 Agent prompt and selected skill IDs from raw project metadata, but classify deletable generated Agent files only against the old runtime's normalized manifest; any mismatch remains uncertain and is moved into the migration backup.
|
||||
Evidence: the focused migration matrix preserves a retired legacy skill ID and prompt whitespace while deleting two exact generated files, backing up one modified generated file plus one unknown file, and leaving unrelated `.opencode/skills` content intact.
|
||||
Future impact: `PI-140` can remove old runtime code without reimplementing or widening the data-cleanup boundary.
|
||||
Semantic conflicts: none; this is the conservative implementation of PI-020 Required work 6 and Acceptance data-preservation rules.
|
||||
Human confirmation required: no.
|
||||
27
electron/coding-projects/atomic-json.ts
Normal file
27
electron/coding-projects/atomic-json.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export type JsonFileWriter = (filePath: string, value: unknown) => Promise<void>;
|
||||
|
||||
export async function atomicWriteText(filePath: string, source: string): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
try {
|
||||
await writeFile(temporaryPath, source, { encoding: 'utf8', flag: 'wx' });
|
||||
await rename(temporaryPath, filePath);
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
||||
await atomicWriteText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(filePath, 'utf8')) as unknown;
|
||||
}
|
||||
257
electron/coding-projects/conversation-store.ts
Normal file
257
electron/coding-projects/conversation-store.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { ConversationModelState, ProductModelRef } from '../coding-runtime/contracts';
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
|
||||
import { normalizeProductModelRef } from './project-config';
|
||||
|
||||
export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json';
|
||||
|
||||
export interface CodingConversationV2 extends ConversationModelState {
|
||||
id: string;
|
||||
agentId: string;
|
||||
title: string;
|
||||
piSessionId?: string;
|
||||
sessionKey?: string;
|
||||
archivedAt: string | null;
|
||||
unread: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingConversationFileV2 {
|
||||
schemaVersion: 2;
|
||||
conversations: CodingConversationV2[];
|
||||
}
|
||||
|
||||
export interface CreateCodingConversationInput {
|
||||
agentId: string;
|
||||
title: string;
|
||||
model: ProductModelRef | null;
|
||||
modelResolution: 'resolved' | 'required';
|
||||
}
|
||||
|
||||
export interface PiSessionBinding {
|
||||
piSessionId: string;
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const SESSION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
function conversationFilePath(projectPath: string): string {
|
||||
return path.join(projectPath, CODING_CONVERSATIONS_PATH);
|
||||
}
|
||||
|
||||
function cleanString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeModelState(value: {
|
||||
model?: unknown;
|
||||
modelResolution?: unknown;
|
||||
}): ConversationModelState {
|
||||
if (value.modelResolution === 'required') {
|
||||
if (value.model !== null) throw new Error('Required Conversation model must be null');
|
||||
return { model: null, modelResolution: 'required' };
|
||||
}
|
||||
if (value.modelResolution !== 'resolved') throw new Error('Conversation model resolution is invalid');
|
||||
return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' };
|
||||
}
|
||||
|
||||
export function validateSessionKey(value: unknown): string {
|
||||
const sessionKey = typeof value === 'string' ? value : '';
|
||||
if (!SESSION_KEY_PATTERN.test(sessionKey) || path.isAbsolute(sessionKey)) {
|
||||
throw new Error('Pi session key must be an opaque relative key');
|
||||
}
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
function normalizeConversation(value: unknown): CodingConversationV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Coding Conversation must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingConversationV2>;
|
||||
const id = cleanString(record.id);
|
||||
const agentId = cleanString(record.agentId);
|
||||
const title = cleanString(record.title);
|
||||
const createdAt = cleanString(record.createdAt);
|
||||
const updatedAt = cleanString(record.updatedAt);
|
||||
if (!UUID_PATTERN.test(id)) throw new Error('Conversation id must be a UUID');
|
||||
if (!agentId) throw new Error('Conversation Agent id is required');
|
||||
if (!title) throw new Error('Conversation title is required');
|
||||
if (!createdAt || !updatedAt) throw new Error('Conversation timestamps are required');
|
||||
const sessionKey = record.sessionKey === undefined ? undefined : validateSessionKey(record.sessionKey);
|
||||
const piSessionId = cleanString(record.piSessionId) || undefined;
|
||||
if ((sessionKey === undefined) !== (piSessionId === undefined)) {
|
||||
throw new Error('Pi session id and session key must be persisted together');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
agentId,
|
||||
title,
|
||||
...normalizeModelState(record),
|
||||
...(piSessionId ? { piSessionId } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
archivedAt: cleanString(record.archivedAt) || null,
|
||||
unread: record.unread === true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyConversationFileV2(): CodingConversationFileV2 {
|
||||
return { schemaVersion: 2, conversations: [] };
|
||||
}
|
||||
|
||||
export function normalizeCodingConversationFileV2(value: unknown): CodingConversationFileV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Coding Conversation file must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingConversationFileV2>;
|
||||
if (record.schemaVersion !== 2) throw new Error('Unsupported coding Conversation schema');
|
||||
if (!Array.isArray(record.conversations)) throw new Error('Conversations must be an array');
|
||||
const conversations = record.conversations.map(normalizeConversation);
|
||||
if (new Set(conversations.map((conversation) => conversation.id)).size !== conversations.length) {
|
||||
throw new Error('Duplicate Conversation id');
|
||||
}
|
||||
return { schemaVersion: 2, conversations };
|
||||
}
|
||||
|
||||
export function createCodingConversationStore(
|
||||
projectPath: string,
|
||||
options: {
|
||||
createId?: () => string;
|
||||
now?: () => string;
|
||||
writer?: JsonFileWriter;
|
||||
} = {},
|
||||
) {
|
||||
const createId = options.createId ?? randomUUID;
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
const writer = options.writer ?? atomicWriteJson;
|
||||
const bindingFlights = new Map<string, Promise<CodingConversationV2>>();
|
||||
let mutationTail = Promise.resolve();
|
||||
|
||||
async function readNow(): Promise<CodingConversationFileV2> {
|
||||
try {
|
||||
return normalizeCodingConversationFileV2(await readJsonFile(conversationFilePath(projectPath)));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyConversationFileV2();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function mutate<T>(operation: (file: CodingConversationFileV2) => Promise<{
|
||||
result: T;
|
||||
file: CodingConversationFileV2;
|
||||
}>): Promise<T> {
|
||||
const execute = async () => {
|
||||
const change = await operation(await readNow());
|
||||
const normalized = normalizeCodingConversationFileV2(change.file);
|
||||
await writer(conversationFilePath(projectPath), normalized);
|
||||
return change.result;
|
||||
};
|
||||
const result = mutationTail.then(execute, execute);
|
||||
mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getConversation(conversationId: string): Promise<CodingConversationV2 | null> {
|
||||
await mutationTail;
|
||||
return (await readNow()).conversations.find((item) => item.id === conversationId) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
async read(): Promise<CodingConversationFileV2> {
|
||||
await mutationTail;
|
||||
return await readNow();
|
||||
},
|
||||
|
||||
async create(input: CreateCodingConversationInput): Promise<CodingConversationV2> {
|
||||
return await mutate(async (file) => {
|
||||
const timestamp = now();
|
||||
const conversation = normalizeConversation({
|
||||
id: createId(),
|
||||
agentId: input.agentId,
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modelResolution: input.modelResolution,
|
||||
archivedAt: null,
|
||||
unread: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return {
|
||||
result: conversation,
|
||||
file: { schemaVersion: 2, conversations: [conversation, ...file.conversations] },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
get: getConversation,
|
||||
|
||||
async patchMetadata(
|
||||
conversationId: string,
|
||||
patch: Partial<Pick<CodingConversationV2, 'title' | 'archivedAt' | 'unread'>>,
|
||||
): Promise<CodingConversationV2> {
|
||||
return await mutate(async (file) => {
|
||||
const current = file.conversations.find((item) => item.id === conversationId);
|
||||
if (!current) throw new Error('Conversation does not exist');
|
||||
const updated = normalizeConversation({
|
||||
...current,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}),
|
||||
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
|
||||
updatedAt: now(),
|
||||
});
|
||||
return {
|
||||
result: updated,
|
||||
file: {
|
||||
schemaVersion: 2,
|
||||
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async ensureSessionBinding(
|
||||
conversationId: string,
|
||||
createBinding: () => Promise<PiSessionBinding>,
|
||||
): Promise<CodingConversationV2> {
|
||||
const inFlight = bindingFlights.get(conversationId);
|
||||
if (inFlight) return await inFlight;
|
||||
const flight = (async () => {
|
||||
const current = await getConversation(conversationId);
|
||||
if (!current) throw new Error('Conversation does not exist');
|
||||
if (current.sessionKey && current.piSessionId) return current;
|
||||
const binding = await createBinding();
|
||||
const sessionKey = validateSessionKey(binding.sessionKey);
|
||||
const piSessionId = cleanString(binding.piSessionId);
|
||||
if (!piSessionId) throw new Error('Pi session id is required');
|
||||
return await mutate(async (file) => {
|
||||
const latest = file.conversations.find((item) => item.id === conversationId);
|
||||
if (!latest) throw new Error('Conversation does not exist');
|
||||
if (latest.sessionKey && latest.piSessionId) return { result: latest, file };
|
||||
const updated = normalizeConversation({
|
||||
...latest,
|
||||
sessionKey,
|
||||
piSessionId,
|
||||
updatedAt: now(),
|
||||
});
|
||||
return {
|
||||
result: updated,
|
||||
file: {
|
||||
schemaVersion: 2,
|
||||
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
|
||||
},
|
||||
};
|
||||
});
|
||||
})();
|
||||
bindingFlights.set(conversationId, flight);
|
||||
try {
|
||||
return await flight;
|
||||
} finally {
|
||||
bindingFlights.delete(conversationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
318
electron/coding-projects/migration.ts
Normal file
318
electron/coding-projects/migration.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
unlink,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ProductModelRef } from '../coding-runtime/contracts';
|
||||
import {
|
||||
buildProjectAgentManifest,
|
||||
normalizeProjectConfig,
|
||||
} from '../opencode/project-config';
|
||||
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');
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(agentDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expected = new Map(buildProjectAgentManifest(config).entries.map((entry) => [
|
||||
path.basename(entry.relativePath),
|
||||
entry.content,
|
||||
]));
|
||||
const snapshots: AgentFileSnapshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(agentDirectory, entry.name);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const generated = expected.get(entry.name) === content;
|
||||
if (generated) {
|
||||
snapshots.push({ fileName: entry.name, filePath, content, generated });
|
||||
continue;
|
||||
}
|
||||
const backupPath = path.join(backupDirectory, '.opencode', 'agent', entry.name);
|
||||
await mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await copy(filePath, backupPath);
|
||||
snapshots.push({ fileName: entry.name, 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 = normalizeProjectConfig(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',
|
||||
`opencode-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),
|
||||
};
|
||||
}
|
||||
334
electron/coding-projects/project-config.ts
Normal file
334
electron/coding-projects/project-config.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
isProjectAgentAvatarDataUrl,
|
||||
isProjectType,
|
||||
type ProjectAgentResponsibility,
|
||||
type ProjectType,
|
||||
} from '../../shared/project-config';
|
||||
import type {
|
||||
ConversationModelState,
|
||||
ConversationThinkingLevel,
|
||||
ProductModelRef,
|
||||
} from '../coding-runtime/contracts';
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
|
||||
|
||||
export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json';
|
||||
export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const;
|
||||
export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number];
|
||||
|
||||
export interface CodingProjectAgentV2 extends ConversationModelState {
|
||||
id: string;
|
||||
avatarId: string;
|
||||
avatarDataUrl?: string;
|
||||
roleName: string;
|
||||
name: string;
|
||||
builtIn: boolean;
|
||||
enabled: boolean;
|
||||
skillIds: string[];
|
||||
responsibility: ProjectAgentResponsibility;
|
||||
prompt: string;
|
||||
archivedAt: string | null;
|
||||
pinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingProjectConfigV2 {
|
||||
schemaVersion: 2;
|
||||
projectType: ProjectType;
|
||||
initialized: boolean;
|
||||
agents: CodingProjectAgentV2[];
|
||||
knowledgeDirectory: 'knowledge';
|
||||
legacyConversationNotice: LegacyConversationNotice;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CodingProjectConfigReadResult =
|
||||
| { status: 'valid'; config: CodingProjectConfigV2 }
|
||||
| { status: 'missing' }
|
||||
| { status: 'invalid'; error: string };
|
||||
|
||||
export interface CreateCodingProjectAgentInput {
|
||||
id: string;
|
||||
avatarId: string;
|
||||
avatarDataUrl?: string;
|
||||
roleName: string;
|
||||
name: string;
|
||||
model: ProductModelRef | null;
|
||||
modelResolution: 'resolved' | 'required';
|
||||
skillIds?: string[];
|
||||
responsibility: ProjectAgentResponsibility;
|
||||
prompt?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/;
|
||||
const THINKING_LEVELS = new Set<ConversationThinkingLevel>([
|
||||
'off',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
]);
|
||||
|
||||
function projectConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, CODING_PROJECT_CONFIG_PATH);
|
||||
}
|
||||
|
||||
function cleanString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cleanStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.map(cleanString).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeResponsibility(value: unknown): ProjectAgentResponsibility {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Project Agent responsibility is invalid');
|
||||
}
|
||||
const record = value as Partial<ProjectAgentResponsibility>;
|
||||
if (typeof record.mission !== 'string'
|
||||
|| !Array.isArray(record.owns)
|
||||
|| !Array.isArray(record.boundaries)
|
||||
|| !Array.isArray(record.collaborators)
|
||||
|| !Array.isArray(record.principles)) {
|
||||
throw new Error('Project Agent responsibility is invalid');
|
||||
}
|
||||
return {
|
||||
mission: cleanString(record.mission),
|
||||
owns: cleanStringList(record.owns),
|
||||
boundaries: cleanStringList(record.boundaries),
|
||||
collaborators: cleanStringList(record.collaborators),
|
||||
principles: cleanStringList(record.principles),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProductModelRef(value: unknown): ProductModelRef {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Product model must be an object');
|
||||
}
|
||||
const record = value as Partial<ProductModelRef>;
|
||||
const accountId = cleanString(record.accountId);
|
||||
const modelId = cleanString(record.modelId);
|
||||
if (!accountId || !modelId || !THINKING_LEVELS.has(record.thinkingLevel as ConversationThinkingLevel)) {
|
||||
throw new Error('Product model reference is invalid');
|
||||
}
|
||||
return {
|
||||
accountId,
|
||||
modelId,
|
||||
thinkingLevel: record.thinkingLevel as ConversationThinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelState(value: {
|
||||
model?: unknown;
|
||||
modelResolution?: unknown;
|
||||
}): ConversationModelState {
|
||||
if (value.modelResolution === 'required') {
|
||||
if (value.model !== null) throw new Error('Required model selection must not contain a model');
|
||||
return { model: null, modelResolution: 'required' };
|
||||
}
|
||||
if (value.modelResolution !== 'resolved') throw new Error('Project Agent model resolution is invalid');
|
||||
return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' };
|
||||
}
|
||||
|
||||
function normalizeAgent(value: unknown): CodingProjectAgentV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Project Agent must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingProjectAgentV2>;
|
||||
const id = cleanString(record.id);
|
||||
const name = cleanString(record.name);
|
||||
const avatarId = cleanString(record.avatarId);
|
||||
const roleName = cleanString(record.roleName);
|
||||
const createdAt = cleanString(record.createdAt);
|
||||
const updatedAt = cleanString(record.updatedAt);
|
||||
if (!AGENT_ID_PATTERN.test(id)) throw new Error('Project Agent id is invalid');
|
||||
if (!name) throw new Error('Project Agent name is required');
|
||||
if (!AVATAR_ID_PATTERN.test(avatarId)) throw new Error('Project Agent avatar is invalid');
|
||||
if (!roleName) throw new Error('Project Agent role is required');
|
||||
if (!createdAt || !updatedAt) throw new Error('Project Agent timestamps are required');
|
||||
if (typeof record.builtIn !== 'boolean'
|
||||
|| typeof record.enabled !== 'boolean'
|
||||
|| typeof record.pinned !== 'boolean'
|
||||
|| !Array.isArray(record.skillIds)
|
||||
|| typeof record.prompt !== 'string'
|
||||
|| !(record.archivedAt === null || typeof record.archivedAt === 'string')) {
|
||||
throw new Error('Project Agent metadata is invalid');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
avatarId,
|
||||
...(isProjectAgentAvatarDataUrl(record.avatarDataUrl)
|
||||
? { avatarDataUrl: record.avatarDataUrl }
|
||||
: {}),
|
||||
roleName,
|
||||
name,
|
||||
builtIn: record.builtIn === true,
|
||||
enabled: record.enabled !== false,
|
||||
...normalizeModelState(record),
|
||||
skillIds: cleanStringList(record.skillIds),
|
||||
responsibility: normalizeResponsibility(record.responsibility),
|
||||
prompt: record.prompt,
|
||||
archivedAt: cleanString(record.archivedAt) || null,
|
||||
pinned: record.pinned === true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAgentNames(agents: CodingProjectAgentV2[]): void {
|
||||
const names = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (agent.name.length > 30) throw new Error('Project Agent name is too long');
|
||||
const key = agent.name.toLocaleLowerCase();
|
||||
if (names.has(key)) throw new Error('Duplicate project Agent name');
|
||||
names.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectConfigV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Coding project config must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingProjectConfigV2>;
|
||||
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
|
||||
if (!isProjectType(record.projectType)) throw new Error('Invalid project type');
|
||||
if (typeof record.initialized !== 'boolean') throw new Error('Project initialized state is invalid');
|
||||
if (record.knowledgeDirectory !== 'knowledge') throw new Error('Project knowledge directory is invalid');
|
||||
if (!Array.isArray(record.agents)) throw new Error('Project Agents must be an array');
|
||||
const createdAt = cleanString(record.createdAt);
|
||||
const updatedAt = cleanString(record.updatedAt);
|
||||
if (!createdAt || !updatedAt) throw new Error('Project timestamps are required');
|
||||
if (!LEGACY_CONVERSATION_NOTICE_VALUES.includes(record.legacyConversationNotice as LegacyConversationNotice)) {
|
||||
throw new Error('Legacy Conversation notice state is invalid');
|
||||
}
|
||||
const agents = record.agents.map(normalizeAgent);
|
||||
if (new Set(agents.map((agent) => agent.id)).size !== agents.length) {
|
||||
throw new Error('Duplicate project Agent id');
|
||||
}
|
||||
validateAgentNames(agents);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
projectType: record.projectType,
|
||||
initialized: record.initialized === true,
|
||||
agents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodingProjectConfigV2(
|
||||
now = new Date().toISOString(),
|
||||
projectType: ProjectType = 'custom',
|
||||
): CodingProjectConfigV2 {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
projectType,
|
||||
initialized: false,
|
||||
agents: [],
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'none',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCodingProjectConfigV2(
|
||||
projectPath: string,
|
||||
): Promise<CodingProjectConfigReadResult> {
|
||||
try {
|
||||
return {
|
||||
status: 'valid',
|
||||
config: normalizeCodingProjectConfigV2(await readJsonFile(projectConfigPath(projectPath))),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCodingProjectConfigV2(
|
||||
projectPath: string,
|
||||
value: unknown,
|
||||
writer: JsonFileWriter = atomicWriteJson,
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
const config = normalizeCodingProjectConfigV2(value);
|
||||
await writer(projectConfigPath(projectPath), config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function createCodingProjectMetadata(
|
||||
projectPath: string,
|
||||
options: {
|
||||
projectType?: ProjectType;
|
||||
now?: string;
|
||||
writer?: JsonFileWriter;
|
||||
} = {},
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
try {
|
||||
await stat(projectConfigPath(projectPath));
|
||||
throw new Error('Coding project configuration already exists');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
const config = createCodingProjectConfigV2(options.now, options.projectType);
|
||||
await Promise.all([
|
||||
mkdir(path.join(projectPath, '.niancode'), { recursive: true }),
|
||||
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),
|
||||
]);
|
||||
await (options.writer ?? atomicWriteJson)(projectConfigPath(projectPath), config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function createCodingProjectAgent(
|
||||
projectPath: string,
|
||||
input: CreateCodingProjectAgentInput,
|
||||
options: { now?: string; writer?: JsonFileWriter } = {},
|
||||
): Promise<CodingProjectAgentV2> {
|
||||
const result = await readCodingProjectConfigV2(projectPath);
|
||||
if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid');
|
||||
if (result.config.agents.some((agent) => agent.id === input.id.trim())) {
|
||||
throw new Error('Project Agent id already exists');
|
||||
}
|
||||
const now = options.now ?? new Date().toISOString();
|
||||
const agent = normalizeAgent({
|
||||
...input,
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
skillIds: input.skillIds ?? [],
|
||||
prompt: input.prompt ?? '',
|
||||
archivedAt: null,
|
||||
pinned: input.pinned ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await writeCodingProjectConfigV2(projectPath, {
|
||||
...result.config,
|
||||
initialized: true,
|
||||
agents: [...result.config.agents, agent],
|
||||
updatedAt: now,
|
||||
}, options.writer);
|
||||
return agent;
|
||||
}
|
||||
|
||||
export async function acknowledgeLegacyConversationNotice(
|
||||
projectPath: string,
|
||||
options: { now?: string; writer?: JsonFileWriter } = {},
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
const result = await readCodingProjectConfigV2(projectPath);
|
||||
if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid');
|
||||
if (result.config.legacyConversationNotice !== 'pending') return result.config;
|
||||
return await writeCodingProjectConfigV2(projectPath, {
|
||||
...result.config,
|
||||
legacyConversationNotice: 'acknowledged',
|
||||
updatedAt: options.now ?? new Date().toISOString(),
|
||||
}, options.writer);
|
||||
}
|
||||
180
electron/coding-projects/project-store.ts
Normal file
180
electron/coding-projects/project-store.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { ProjectType } from '../../shared/project-config';
|
||||
import {
|
||||
createCodingProjectMetadata,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
|
||||
export interface CodingProject {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastOpenedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingProjectStoreData {
|
||||
projects: Record<string, CodingProject>;
|
||||
activeProjectId: string | null;
|
||||
}
|
||||
|
||||
export interface CodingProjectStorage {
|
||||
read(): Promise<CodingProjectStoreData | undefined>;
|
||||
write(data: CodingProjectStoreData): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CodingProjectKeyValueStore {
|
||||
get(key: string): unknown;
|
||||
set(key: string, value: CodingProjectStoreData): unknown;
|
||||
}
|
||||
|
||||
export type CodingProjectStore = ReturnType<typeof createCodingProjectStore>;
|
||||
|
||||
const DEFAULT_PROJECT_STORE_KEY = 'coding-projects-v2';
|
||||
|
||||
export function normalizeCodingProjectPath(input: string): string {
|
||||
const normalized = path.normalize(path.resolve(input.trim()));
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function createMemoryCodingProjectStorage(
|
||||
initial?: CodingProjectStoreData,
|
||||
): CodingProjectStorage {
|
||||
let stored = initial ? structuredClone(initial) : undefined;
|
||||
return {
|
||||
async read() {
|
||||
return stored ? structuredClone(stored) : undefined;
|
||||
},
|
||||
async write(data) {
|
||||
stored = structuredClone(data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodingProjectStorageFromStore(
|
||||
store: CodingProjectKeyValueStore,
|
||||
key = DEFAULT_PROJECT_STORE_KEY,
|
||||
): CodingProjectStorage {
|
||||
return {
|
||||
async read() {
|
||||
const data = store.get(key);
|
||||
return data ? structuredClone(data) as CodingProjectStoreData : undefined;
|
||||
},
|
||||
async write(data) {
|
||||
store.set(key, structuredClone(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStoreData(): CodingProjectStoreData {
|
||||
return { projects: {}, activeProjectId: null };
|
||||
}
|
||||
|
||||
export function createCodingProjectStore(
|
||||
storage: CodingProjectStorage,
|
||||
options: {
|
||||
createId?: () => string;
|
||||
now?: () => string;
|
||||
} = {},
|
||||
) {
|
||||
const createId = options.createId ?? randomUUID;
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
let mutationTail = Promise.resolve();
|
||||
|
||||
async function readData(): Promise<CodingProjectStoreData> {
|
||||
return (await storage.read()) ?? emptyStoreData();
|
||||
}
|
||||
|
||||
function mutate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = mutationTail.then(operation, operation);
|
||||
mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function upsertProject(input: string, activate: boolean): Promise<CodingProject> {
|
||||
return await mutate(async () => {
|
||||
const data = await readData();
|
||||
const normalizedPath = normalizeCodingProjectPath(input);
|
||||
const existing = Object.values(data.projects)
|
||||
.find((project) => project.path === normalizedPath);
|
||||
const timestamp = now();
|
||||
const project: CodingProject = existing
|
||||
? {
|
||||
...existing,
|
||||
name: path.basename(normalizedPath),
|
||||
updatedAt: timestamp,
|
||||
lastOpenedAt: timestamp,
|
||||
}
|
||||
: {
|
||||
id: createId(),
|
||||
path: normalizedPath,
|
||||
name: path.basename(normalizedPath),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastOpenedAt: timestamp,
|
||||
};
|
||||
data.projects[project.id] = project;
|
||||
if (activate) data.activeProjectId = project.id;
|
||||
await storage.write(data);
|
||||
return project;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async openFolder(input: string): Promise<CodingProject> {
|
||||
return await upsertProject(input, true);
|
||||
},
|
||||
|
||||
async rememberProject(input: string): Promise<CodingProject> {
|
||||
return await upsertProject(input, false);
|
||||
},
|
||||
|
||||
async listProjects(): Promise<CodingProject[]> {
|
||||
await mutationTail;
|
||||
return Object.values((await readData()).projects)
|
||||
.sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt));
|
||||
},
|
||||
|
||||
async getActiveProject(): Promise<CodingProject | null> {
|
||||
await mutationTail;
|
||||
const data = await readData();
|
||||
return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null;
|
||||
},
|
||||
|
||||
async setActiveProject(projectId: string | null): Promise<CodingProject | null> {
|
||||
return await mutate(async () => {
|
||||
const data = await readData();
|
||||
data.activeProjectId = projectId && data.projects[projectId] ? projectId : null;
|
||||
await storage.write(data);
|
||||
return data.activeProjectId ? data.projects[data.activeProjectId] : null;
|
||||
});
|
||||
},
|
||||
|
||||
async removeProject(projectId: string): Promise<void> {
|
||||
await mutate(async () => {
|
||||
const data = await readData();
|
||||
delete data.projects[projectId];
|
||||
if (data.activeProjectId === projectId) data.activeProjectId = null;
|
||||
await storage.write(data);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLocalCodingProject(
|
||||
input: {
|
||||
projectPath: string;
|
||||
projectType?: ProjectType;
|
||||
now?: string;
|
||||
},
|
||||
store: CodingProjectStore,
|
||||
): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> {
|
||||
const config = await createCodingProjectMetadata(input.projectPath, {
|
||||
projectType: input.projectType,
|
||||
now: input.now,
|
||||
});
|
||||
const project = await store.openFolder(input.projectPath);
|
||||
return { project, config };
|
||||
}
|
||||
261
tests/unit/coding-projects-migration.test.ts
Normal file
261
tests/unit/coding-projects-migration.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
230
tests/unit/coding-projects-schema-v2.test.ts
Normal file
230
tests/unit/coding-projects-schema-v2.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user