Files
makelore/electron/coding-projects/conversation-store.ts

288 lines
10 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { CodingConversationMetadata } from '../../shared/coding-project-contracts';
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 = '.makelore/conversations.json';
export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState {
piSessionId?: string;
sessionKey?: 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 setModelState(
conversationId: string,
modelState: ConversationModelState,
): 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,
...modelState,
updatedAt: now(),
});
return {
result: updated,
file: {
schemaVersion: 2,
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
},
};
});
},
async delete(conversationId: string): Promise<void> {
await mutate(async (file) => {
const current = file.conversations.find((item) => item.id === conversationId);
if (!current) throw new Error('Conversation does not exist');
return {
result: undefined,
file: {
schemaVersion: 2,
conversations: file.conversations.filter((item) => item.id !== conversationId),
},
};
});
},
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);
}
},
};
}