fix: isolate delivered agent chats by project

This commit is contained in:
2026-09-28 16:21:14 +08:00
parent a21a1f077c
commit 4b41c23a3a
15 changed files with 518 additions and 165 deletions

View File

@@ -12,6 +12,7 @@ interface Manifest {
topic: Omit<TeacherTopic, 'requests'>;
turns: TurnIndex[];
importedDiscussions?: Record<string, string>;
importedGlobal?: boolean;
}
const PAGE_SIZE = 50;
const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === 'ENOENT';
@@ -28,7 +29,8 @@ export class TeacherConversationStore {
private loaded?: Promise<void>;
private writes: Promise<unknown> = Promise.resolve();
private readonly importedProjects = new Set<string>();
constructor(readonly directory: string, readonly accountId: string, readonly agentId: string) {}
private checkedGlobal = false;
constructor(readonly directory: string, readonly accountId: string, readonly agentId: string, readonly projectId: string) {}
private manifestPath() { return path.join(this.directory, 'conversation.json'); }
private turnPath(id: string) { return path.join(this.directory, 'turns', teacherTopicId(id) + '.json'); }
@@ -41,7 +43,7 @@ export class TeacherConversationStore {
catch (error) { if (isMissing(error)) return; throw error; }
const manifest = this.manifest;
if (manifest.topic.accountId !== this.accountId || manifest.topic.conversation?.agentId !== this.agentId
|| !Array.isArray(manifest.turns))
|| manifest.topic.projectId !== this.projectId || !Array.isArray(manifest.turns))
throw new TeacherError(409, 'teacher_history_invalid', '智能体历史无法读取,请保留本机记录。');
// A turn can finish writing just before the index write is interrupted.
// Recover only unindexed files, without reading every historical body.
@@ -84,7 +86,7 @@ export class TeacherConversationStore {
const now = new Date().toISOString();
const topic: TeacherTopic = {
schemaVersion: 1, revision: 0, id: randomUUID(), accountId: this.accountId,
projectId: '', sourceConversationId: 'project', definition: structuredClone(definition), version,
projectId: this.projectId, sourceConversationId: 'project', definition: structuredClone(definition), version,
createdAt: now, updatedAt: now, requests: [],
conversation: { agentId: this.agentId, segmentTurns: 0, discussions: {} },
};
@@ -151,7 +153,8 @@ export class TeacherConversationStore {
}
turns.sort(order);
const { requests: _requests, unsaved: _unsaved, ...metadata } = topic;
const next = { topic: metadata, turns, importedDiscussions: this.manifest.importedDiscussions };
const next = { topic: metadata, turns, importedDiscussions: this.manifest.importedDiscussions,
importedGlobal: this.manifest.importedGlobal };
try { await atomicWriteJson(this.manifestPath(), next); }
catch (error) {
// The body may already be durable. Reload and recover it before a retry,
@@ -169,7 +172,7 @@ export class TeacherConversationStore {
topic.requests = (await this.page()).requests;
return topic;
}
async select(_id: string) { /* There is only one conversation per agent. */ }
async select(_id: string) { /* One conversation per project and agent. */ }
async list() {
await this.load();
const topic = this.live;
@@ -185,8 +188,64 @@ export class TeacherConversationStore {
topic.revision++;
await this.save(topic, '');
}
/** Old files remain untouched. Only a proven account + config ID can be imported. */
/** Split the former account-wide chat using recorded turn ownership; never mutate its files. */
async importGlobal(directory: string) {
await this.load();
if (this.checkedGlobal || this.manifest?.importedGlobal) return;
let old: Manifest;
try { old = await readJsonFile(path.join(directory, 'conversation.json')) as Manifest; }
catch (error) { if (isMissing(error)) { this.checkedGlobal = true; return; } throw error; }
if (old.topic.accountId !== this.accountId || old.topic.conversation?.agentId !== this.agentId) {
this.checkedGlobal = true; return;
}
const ids = new Set(old.turns.map(turn => turn.id));
// Include a body whose original index write was interrupted.
try {
for (const name of await readdir(path.join(directory, 'turns')))
if (/^[0-9a-f-]{36}\.json$/i.test(name)) ids.add(name.slice(0, -5));
} catch (error) { if (!isMissing(error)) throw error; }
const seenIndex = old.turns.findIndex(turn => turn.id === old.topic.conversation?.seenThrough);
const seenIds = new Set(old.turns.slice(0, seenIndex + 1).map(turn => turn.id));
let seen: string | undefined;
for (const id of ids) {
const request = await readJsonFile(path.join(directory, 'turns', teacherTopicId(id) + '.json')) as TeacherRequest;
if ((request.projectId ?? request.origin?.projectId) !== this.projectId) continue;
const topic = await this.ensure(old.topic.definition, old.topic.version);
if (seenIds.has(id)) seen = id;
if (this.manifest!.turns.some(turn => turn.id === id)) continue;
const turn: TeacherRequest = { ...structuredClone(request), projectId: this.projectId,
// A split chat must start a new cloud checkpoint, since the old one may contain other projects.
origin: request.origin ?? { projectId: this.projectId, topicId: old.topic.id, requestId: id },
...(old.topic.definition.runtime === 'yuxi' ? { cloudRequestId: request.cloudRequestId ?? id } : {}) };
if (turn.status === 'running' || turn.status === 'preparing') {
turn.status = 'interrupted'; turn.error = '项目聊天已分开,本次回复中断。';
}
topic.requests.push(turn);
topic.updatedAt = [topic.updatedAt, turn.createdAt].sort().at(-1)!;
topic.revision++;
await this.save(topic, turn.id);
topic.requests = topic.requests.slice(-PAGE_SIZE);
}
// Retain an empty project's discussion too, without assigning unowned turns to it.
const discussion = old.topic.conversation.discussions[this.projectId];
if (discussion) {
const topic = await this.ensure(old.topic.definition, old.topic.version);
if (!topic.conversation!.discussions[this.projectId]) {
topic.conversation!.discussions[this.projectId] = structuredClone(discussion);
(this.manifest!.importedDiscussions ??= {})[this.projectId] = old.topic.updatedAt;
}
}
if (this.manifest) {
if (seen) await this.markSeen(seen);
this.manifest.importedGlobal = true;
await this.save(await this.read(), '');
await this.recent();
}
this.checkedGlobal = true;
}
/** Old files remain untouched. Only a proven account + project + config ID can be imported. */
async importProject(project: { id: string; path: string; name: string }) {
if (project.id !== this.projectId) throw new Error('Conversation project mismatch');
if (this.importedProjects.has(project.id)) return;
await this.load();
const root = path.join(project.path, '.makelore', 'teacher-conversations', this.accountId);