feat(agent): unify MCP tools and adopt Responses delivery

Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+108 -24
View File
@@ -1,12 +1,12 @@
import { join } from "node:path";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
atomicWriteJson,
ensureDirectory,
listJsonFiles,
readJsonFile,
removeFileIfExists,
slugify,
} from "../utils/fileStore.js";
@@ -23,6 +23,7 @@ export type SessionRecord = {
createdAt: string;
updatedAt: string;
status: SessionStatus;
deletedAt?: string;
purpose?: SessionPurpose;
taskId?: string;
runId?: string;
@@ -65,13 +66,33 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
};
export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
private readonly ensureQueues = new Map<string, Promise<void>>();
private index?: Map<string, Map<string, SessionRecord>>;
private indexLoading?: Promise<void>;
private indexVersion = 0;
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
}
async initialize() {
await ensureDirectory(this.baseDir);
}
async ensure(input: EnsureSessionMetadataInput) {
return this.serialize(input.sessionId, () => this.ensureRecord(input));
}
private async serialize<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const key = this.filePath(sessionId);
const previous = this.ensureQueues.get(key) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(operation);
const pending = result.then(() => undefined, () => undefined);
this.ensureQueues.set(key, pending);
try { return await result; }
finally { if (this.ensureQueues.get(key) === pending) this.ensureQueues.delete(key); }
}
private async ensureRecord(input: EnsureSessionMetadataInput) {
const sessionId = normalizeSessionId(input.sessionId);
if (!sessionId) {
throw new Error("sessionId is required");
@@ -81,7 +102,7 @@ export class SessionMetadataStore {
);
if (existing) {
if (
!matchesContext(existing, input) ||
existing.deletedAt || existing.sessionId !== sessionId || !matchesContext(existing, input) ||
(input.trigger && existing.triggerId !== input.trigger.triggerId) ||
(input.purpose && readPurpose(existing) !== input.purpose) ||
(input.taskId && existing.taskId !== input.taskId.trim()) ||
@@ -91,10 +112,12 @@ export class SessionMetadataStore {
) {
throw new SessionContextMismatchError();
}
if (this.workspaces) await this.workspaces.ensure({ actorKey: existing.actorKey, projectKey: existing.projectKey, sessionId });
return { created: false, record: existing };
}
const now = new Date().toISOString();
if (this.workspaces) await this.workspaces.ensure({ actorKey: input.actorKey, projectKey: input.projectKey, sessionId });
const record: SessionRecord = {
sessionId,
actorKey: input.actorKey,
@@ -122,11 +145,13 @@ export class SessionMetadataStore {
await atomicWriteJson(
this.filePath(record.sessionId),
record,
0o600,
);
this.updateIndex(record);
return { created: true, record };
}
async get(context: SessionMetadataContext, sessionId: string) {
async get(context: SessionMetadataContext, sessionId: string, options?: { includeDeleted?: boolean }) {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) {
return null;
@@ -134,39 +159,45 @@ export class SessionMetadataStore {
const record = await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId),
);
return record && matchesContext(record, context) ? record : null;
return record && (!record.deletedAt || options?.includeDeleted) && record.sessionId === normalizedSessionId && matchesContext(record, context) ? record : null;
}
// Internal delivery only, after Server has authorized and resolved this run.
// Does not authorize ordinary chat access or expose a global session lookup.
async getTaskExecutionBinding(projectId: string, taskId: string, runId: string, sessionId: string) {
const id = normalizeSessionId(sessionId);
if (!id) return null;
const record = await readJsonFile<SessionRecord>(this.filePath(id));
return record && !record.deletedAt && record.sessionId === id
&& record.projectId === projectId && record.taskId === taskId && record.runId === runId
&& readPurpose(record) === "task_execution" ? record : null;
}
async touch(
record: SessionRecord,
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
) {
const next: SessionRecord = {
...record,
...normalizeSessionUpdates(updates),
updatedAt: new Date().toISOString(),
};
await atomicWriteJson(
this.filePath(record.sessionId),
next,
);
return next;
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
if (!current) throw new SessionContextMismatchError();
const next: SessionRecord = { ...current, ...normalizeSessionUpdates(updates), updatedAt: new Date().toISOString() };
await atomicWriteJson(this.filePath(record.sessionId), next, 0o600);
this.updateIndex(next);
return next;
});
}
async list(
context: SessionMetadataContext,
filter?: { purpose?: SessionPurpose; taskId?: string },
) {
const files = await listJsonFiles(this.baseDir);
const records = await Promise.all(
files.map((file) => readJsonFile<SessionRecord>(file)),
);
await this.loadIndex();
const records = [...(this.index?.get(this.scopeKey(context))?.values() ?? [])].map((record) => ({ ...record }));
return records
.filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey,
!record.deletedAt && matchesContext(record, context),
)
.filter((record) => !filter?.purpose || readPurpose(record) === filter.purpose)
.filter((record) => !filter?.taskId || record.taskId === filter.taskId.trim())
@@ -199,9 +230,62 @@ export class SessionMetadataStore {
}
async remove(record: SessionRecord) {
await removeFileIfExists(
this.filePath(record.sessionId),
);
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true });
if (!current) throw new SessionContextMismatchError();
// Retain only identity and deletion state, so legacy imports and late writes cannot revive it.
const deleted: SessionRecord = {
sessionId: current.sessionId, actorKey: current.actorKey, projectKey: current.projectKey,
ownerUserId: current.ownerUserId, projectId: current.projectId,
createdAt: current.createdAt, updatedAt: new Date().toISOString(), status: "archived",
deletedAt: current.deletedAt ?? new Date().toISOString(),
};
await atomicWriteJson(this.filePath(record.sessionId), deleted, 0o600);
this.updateIndex(deleted);
await this.workspaces?.markDeleted(deleted);
});
}
async purge(record: SessionRecord) {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true });
if (!current?.deletedAt) throw new SessionContextMismatchError();
await this.workspaces?.purge(current);
}
private scopeKey(context: { actorKey: string; projectKey: string }) {
return JSON.stringify([context.actorKey, context.projectKey]);
}
private updateIndex(record: SessionRecord) {
this.indexVersion++;
if (!this.index) return;
const key = this.scopeKey(record);
const records = this.index.get(key) ?? new Map<string, SessionRecord>();
records.set(record.sessionId, { ...record });
this.index.set(key, records);
}
private async loadIndex(): Promise<void> {
if (this.index) return;
if (this.indexLoading) { await this.indexLoading; return this.loadIndex(); }
const version = this.indexVersion;
const loading = (async () => {
const files = await listJsonFiles(this.baseDir);
const records = await Promise.all(files.map((file) => readJsonFile<SessionRecord>(file)));
const index = new Map<string, Map<string, SessionRecord>>();
for (const record of records) {
if (!record) continue;
const key = this.scopeKey(record);
const scoped = index.get(key) ?? new Map<string, SessionRecord>();
scoped.set(record.sessionId, record);
index.set(key, scoped);
}
if (version === this.indexVersion) this.index = index;
})();
this.indexLoading = loading;
try { await loading; }
finally { if (this.indexLoading === loading) this.indexLoading = undefined; }
if (!this.index) await this.loadIndex();
}
private filePath(sessionId: string) {
+30 -7
View File
@@ -9,6 +9,7 @@ import {
toStableId,
} from "../utils/fileStore.js";
import { sanitizePersistentDocument } from "../utils/persistencePolicy.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
export type SessionTurnRecord = {
id: string;
@@ -49,10 +50,10 @@ const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240;
export class SessionTranscriptStore {
private readonly writeQueues = new Map<string, Promise<void>>();
constructor(private readonly baseDir = config.SESSION_TRANSCRIPT_STORAGE_DIR) {}
constructor(private readonly baseDir = config.SESSION_TRANSCRIPT_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
async initialize() {
await ensureDirectory(this.baseDir);
if (!this.workspaces) await ensureDirectory(this.baseDir);
}
async appendTurn(
@@ -65,6 +66,7 @@ export class SessionTranscriptStore {
) {
const key = this.filePath(context);
return this.serializeWrite(key, async () => {
if (this.workspaces) await this.workspaces.ensure(this.workspaceContext(context));
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
const transcript = (await this.readTranscript(context)) ?? {
actorKey: context.actorKey,
@@ -91,7 +93,7 @@ export class SessionTranscriptStore {
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.sessionId = context.sessionId;
transcript.updatedAt = timestamp;
await atomicWriteJson(key, transcript);
await this.persist(context, transcript);
return transcript;
}
@@ -111,7 +113,7 @@ export class SessionTranscriptStore {
);
}
transcript.updatedAt = timestamp;
await atomicWriteJson(key, transcript);
await this.persist(context, transcript);
return transcript;
});
}
@@ -132,6 +134,9 @@ export class SessionTranscriptStore {
targetContext: SessionTranscriptContext,
keepMessageCount: number,
) {
if (sourceContext.actorKey !== targetContext.actorKey || sourceContext.projectKey !== targetContext.projectKey) {
throw new Error("cannot fork chat history across users or scopes");
}
const sourceTranscript = await this.readTranscript(sourceContext);
const timestamp = new Date().toISOString();
// 分叉会话只复制用户选择保留的上下文,后续新分支拥有独立 transcript 文件。
@@ -143,7 +148,11 @@ export class SessionTranscriptStore {
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
updatedAt: timestamp,
};
await atomicWriteJson(this.filePath(targetContext), nextTranscript);
if (this.workspaces) {
await this.workspaces.ensure(this.workspaceContext(targetContext));
await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json");
}
await this.persist(targetContext, nextTranscript);
return nextTranscript;
}
@@ -159,7 +168,7 @@ export class SessionTranscriptStore {
}
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
const hits: SessionSearchHit[] = [];
const files = await listJsonFiles(this.baseDir);
const files = this.workspaces ? await this.workspaces.list(context, "transcript.json") : await listJsonFiles(this.baseDir);
for (const file of files) {
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
if (!transcript) {
@@ -196,16 +205,30 @@ export class SessionTranscriptStore {
}
private async readTranscript(context: SessionTranscriptContext) {
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null;
const record = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
if (record && (record.actorKey !== context.actorKey || record.projectKey !== context.projectKey ||
(record.clientSessionId || record.sessionId) !== (context.clientSessionId || context.sessionId))) throw new Error("chat transcript ownership mismatch");
return record;
}
private filePath(context: SessionTranscriptContext) {
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "transcript.json");
return join(
this.baseDir,
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
);
}
private async persist(context: SessionTranscriptContext, transcript: SessionTranscriptRecord) {
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript);
else await atomicWriteJson(this.filePath(context), transcript);
}
private workspaceContext(context: SessionTranscriptContext) {
return { actorKey: context.actorKey, projectKey: context.projectKey, sessionId: context.clientSessionId || context.sessionId };
}
private async serializeWrite<T>(key: string, task: () => Promise<T>) {
const previous = this.writeQueues.get(key) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(task);
+20 -3
View File
@@ -1,6 +1,7 @@
import { join } from "node:path";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
atomicWriteJson,
ensureDirectory,
@@ -18,29 +19,45 @@ export type SessionUiStateRecord = {
type SessionUiStateContext = {
sessionId: string;
actorKey?: string;
projectKey?: string;
};
export class SessionUiStateStore {
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR) {}
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
async initialize() {
await ensureDirectory(this.baseDir);
if (!this.workspaces) await ensureDirectory(this.baseDir);
}
async read(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null;
return await readJsonFile<SessionUiStateRecord>(this.filePath(context));
}
async write(context: SessionUiStateContext, state: SessionUiStateRecord) {
await atomicWriteJson(this.filePath(context), state);
if (state.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
if (this.workspaces) {
await this.workspaces.ensure(this.workspaceContext(context));
await this.workspaces.validate(this.workspaceContext(context), "messages.json");
}
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
else await atomicWriteJson(this.filePath(context), state);
return state;
}
async remove(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
await removeFileIfExists(this.filePath(context));
}
private filePath(context: SessionUiStateContext) {
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "messages.json");
return join(this.baseDir, `${slugify(context.sessionId)}.json`);
}
private workspaceContext(context: SessionUiStateContext) {
if (!context.actorKey || !context.projectKey) throw new Error("chat workspace user and scope are required");
return { sessionId: context.sessionId, actorKey: context.actorKey, projectKey: context.projectKey };
}
}
+263
View File
@@ -0,0 +1,263 @@
import { constants } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { copyFile, chmod, link, lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { join, relative, resolve, sep } from "node:path";
import {
ensureSessionWorkspaceDirectory, resolveConversationWorkspace, sessionWorkspaceDirectory,
type SessionWorkspaceContext, type SessionWorkspaceScope,
} from "../runtime/conversationWorkspace.js";
import { atomicWriteJson, listJsonFiles, readJsonFile } from "../utils/fileStore.js";
import { SessionContextMismatchError, type SessionRecord } from "./metadataStore.js";
type ChatRecordName = "messages.json" | "transcript.json" | "learning.json";
type LegacySources = { metadataDir: string; uiStateDir: string; transcriptDir: string; learningStateDir?: string };
const exists = async (path: string) => {
try { await lstat(path); return true; }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
};
const createFileExclusively = async (path: string, write: (temporary: string) => Promise<void>) => {
const temporary = `${path}.${randomUUID()}.tmp`;
try {
await write(temporary);
await link(temporary, path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
} finally { await rm(temporary, { force: true }); }
};
export class SessionWorkspaceStore {
private readonly writeQueues = new Map<string, Promise<void>>();
private legacySources?: LegacySources;
private deletionCheck?: (context: SessionWorkspaceContext) => Promise<boolean>;
constructor(readonly root: string, readonly backupRoot = resolve(root, "..", "backup", "legacy-chat")) {}
setDeletionCheck(check: (context: SessionWorkspaceContext) => Promise<boolean>) {
this.deletionCheck = check;
}
path(context: SessionWorkspaceContext, name: ChatRecordName) {
return join(sessionWorkspaceDirectory(this.root, context), "chat", name);
}
async ensure(context: SessionWorkspaceContext, allowDeleted = false) {
const directory = await ensureSessionWorkspaceDirectory(this.root, context);
const chat = join(directory, "chat");
await mkdir(chat, { recursive: true, mode: 0o700 });
if ((await lstat(chat)).isSymbolicLink()) throw new Error("chat directory must not be a symbolic link");
const ownerPath = join(chat, "owner.json");
if (!await exists(ownerPath)) {
const owner = { sessionId: context.sessionId, actorKey: context.actorKey, projectKey: context.projectKey };
await createFileExclusively(ownerPath, (temporary) => writeFile(temporary, JSON.stringify(owner, null, 2), { flag: "wx", mode: 0o600 }));
}
if (!await this.validate(context, undefined, allowDeleted)) throw new SessionContextMismatchError();
return directory;
}
async validate(context: SessionWorkspaceContext, name?: ChatRecordName, allowDeleted = false) {
const directory = sessionWorkspaceDirectory(this.root, context);
if (!await exists(directory)) return false;
// Check each component before following it, including the persisted record.
let current = resolve(this.root);
for (const part of ["", ...relative(current, join(directory, "chat")).split(sep)]) {
if (part) current = join(current, part);
if (!await exists(current)) return false;
if ((await lstat(current)).isSymbolicLink()) throw new Error("chat workspace path must not be a symbolic link");
}
const ownerPath = join(directory, "chat", "owner.json");
if (!await exists(ownerPath)) throw new Error("chat workspace owner is missing");
if ((await lstat(ownerPath)).isSymbolicLink()) throw new Error("chat workspace owner must not be a symbolic link");
const owner = await readJsonFile<SessionWorkspaceContext>(ownerPath);
if (!owner || owner.actorKey !== context.actorKey || owner.projectKey !== context.projectKey || owner.sessionId !== context.sessionId) {
throw new Error("chat workspace ownership mismatch");
}
if (name && await exists(this.path(context, name)) && (await lstat(this.path(context, name))).isSymbolicLink()) {
throw new Error("chat record must not be a symbolic link");
}
const deletedPath = join(directory, "chat", "deleted.json");
if (await exists(deletedPath)) {
if ((await lstat(deletedPath)).isSymbolicLink()) throw new Error("chat deletion marker must not be a symbolic link");
const deleted = await readJsonFile<SessionWorkspaceContext>(deletedPath);
if (!deleted || deleted.actorKey !== context.actorKey || deleted.projectKey !== context.projectKey || deleted.sessionId !== context.sessionId) throw new Error("chat deletion ownership mismatch");
if (!allowDeleted) return false;
}
return allowDeleted || !await this.deletionCheck?.(context);
}
private async serialize<T>(context: SessionWorkspaceContext, operation: () => Promise<T>) {
const key = sessionWorkspaceDirectory(this.root, context);
const previous = this.writeQueues.get(key) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(operation);
const pending = result.then(() => undefined, () => undefined);
this.writeQueues.set(key, pending);
try { return await result; }
finally { if (this.writeQueues.get(key) === pending) this.writeQueues.delete(key); }
}
async writeRecord(context: SessionWorkspaceContext, name: ChatRecordName, value: object) {
return this.serialize(context, async () => {
await this.ensure(context);
if (!await this.validate(context, name)) throw new SessionContextMismatchError();
await atomicWriteJson(this.path(context, name), value as Record<string, unknown>, 0o600);
});
}
async markDeleted(context: SessionWorkspaceContext) {
return this.serialize(context, async () => {
const directory = await this.ensure(context, true);
const path = join(directory, "chat", "deleted.json");
if (!await exists(path)) await atomicWriteJson(path, {
sessionId: context.sessionId, actorKey: context.actorKey, projectKey: context.projectKey,
deletedAt: new Date().toISOString(),
}, 0o600);
});
}
async purge(context: SessionWorkspaceContext) {
return this.serialize(context, async () => {
if (!await this.validate(context, undefined, true)) return;
const directory = sessionWorkspaceDirectory(this.root, context);
if (!await exists(join(directory, "chat", "deleted.json"))) throw new Error("chat must be marked deleted before cleanup");
const chat = join(directory, "chat");
for (const name of await readdir(chat)) {
if (name === "owner.json" || name === "deleted.json") continue;
const file = join(chat, name);
if ((await lstat(file)).isSymbolicLink()) throw new Error("chat record must not be a symbolic link");
await rm(file, { recursive: true, force: true });
}
const workspace = join(directory, "workspace");
if (await exists(workspace)) await rm(await resolveConversationWorkspace(workspace, this.root), { recursive: true });
if (this.legacySources) await this.removeLegacyRecords(context, this.legacySources);
const archives = new SessionWorkspaceStore(this.backupRoot);
if (await archives.validate(context, undefined, true)) {
await rm(sessionWorkspaceDirectory(archives.root, context), { recursive: true });
}
});
}
private async removeLegacyRecords(context: SessionWorkspaceContext, sources: LegacySources) {
for (const [directory, scoped] of [[sources.uiStateDir, false], [sources.transcriptDir, true], [sources.learningStateDir, false]] as const) {
if (!directory) continue;
if (await exists(directory) && (await lstat(directory)).isSymbolicLink()) throw new Error("legacy storage must not be a symbolic link");
for (const file of await listJsonFiles(directory)) {
if ((await lstat(file)).isSymbolicLink()) throw new Error("legacy chat record must not be a symbolic link");
const record = await readJsonFile<Partial<SessionWorkspaceContext> & { clientSessionId?: string }>(file);
if ((record?.clientSessionId || record?.sessionId) !== context.sessionId) continue;
if (scoped && (record?.actorKey !== context.actorKey || record.projectKey !== context.projectKey)) continue;
await rm(file);
}
}
}
async list(scope: SessionWorkspaceScope, name: ChatRecordName) {
const probe = sessionWorkspaceDirectory(this.root, { ...scope, sessionId: "list" });
const project = resolve(probe, "..");
if (!await exists(project)) return [];
for (const path of [this.root, resolve(project, ".."), project]) {
if ((await lstat(path)).isSymbolicLink()) throw new Error("chat workspace path must not be a symbolic link");
}
const paths: string[] = [];
for (const entry of await readdir(project, { withFileTypes: true })) {
if (!entry.isDirectory() || !/^session-[a-f0-9]{64}$/.test(entry.name)) continue;
const chat = join(project, entry.name, "chat");
if (await exists(chat) && (await lstat(chat)).isSymbolicLink()) throw new Error("chat directory must not be a symbolic link");
const ownerPath = join(project, entry.name, "chat", "owner.json");
if (!await exists(ownerPath)) continue;
if ((await lstat(ownerPath)).isSymbolicLink()) throw new Error("chat workspace owner must not be a symbolic link");
const owner = await readJsonFile<SessionWorkspaceContext>(ownerPath);
if (!owner || owner.actorKey !== scope.actorKey || owner.projectKey !== scope.projectKey) continue;
if (resolve(sessionWorkspaceDirectory(this.root, owner)) !== resolve(project, entry.name)) throw new Error("chat workspace ownership mismatch");
if (await this.validate(owner, name) && await exists(this.path(owner, name))) paths.push(this.path(owner, name));
}
return paths;
}
async migrateLegacyRecords(options: LegacySources) {
this.legacySources = options;
const sourceStamp = async () => Promise.all([options.metadataDir, options.uiStateDir, options.transcriptDir, options.learningStateDir].filter((path): path is string => Boolean(path)).map(async (path) => {
try { const value = await lstat(path); if (value.isSymbolicLink()) throw new Error("legacy storage must not be a symbolic link"); return [resolve(path), value.mtimeMs]; }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return [resolve(path), null]; throw error; }
}));
await mkdir(this.backupRoot, { recursive: true, mode: 0o700 });
if ((await lstat(this.backupRoot)).isSymbolicLink()) throw new Error("legacy backup root must not be a symbolic link");
const manifestPath = join(this.backupRoot, "migration.json");
if (await exists(manifestPath) && (await lstat(manifestPath)).isSymbolicLink()) throw new Error("migration manifest must not be a symbolic link");
const stamp = await sourceStamp();
const manifest = await readJsonFile<{ version: number; sources: unknown }>(manifestPath);
const result = { uiStates: 0, transcripts: 0, learningStates: 0, retainedUnownedUiStates: 0 };
if (manifest?.version === 1 && JSON.stringify(manifest.sources) === JSON.stringify(stamp)) return result;
const owners = new Map<string, SessionWorkspaceContext>();
const deleted = new Map<string, SessionWorkspaceContext>();
for (const file of await listJsonFiles(options.metadataDir)) {
const record = await readJsonFile<SessionRecord>(file);
if (!record?.sessionId || !record.actorKey || !record.projectKey) continue;
const context = { sessionId: record.sessionId, actorKey: record.actorKey, projectKey: record.projectKey };
if (record.deletedAt) { await this.markDeleted(context); deleted.set(record.sessionId, context); continue; }
await this.ensure(context);
owners.set(record.sessionId, context);
}
for (const file of await listJsonFiles(options.uiStateDir)) {
const record = await readJsonFile<{ sessionId: string }>(file);
if (record && deleted.has(record.sessionId)) { await rm(file); continue; }
const owner = record && owners.get(record.sessionId);
if (!owner) { result.retainedUnownedUiStates++; continue; }
if (await this.copyLegacyRecord(owner, "messages.json", file)) result.uiStates++;
await this.archiveLegacyRecord(owner, "messages.json", file);
}
for (const file of await listJsonFiles(options.transcriptDir)) {
const record = await readJsonFile<{ actorKey: string; projectKey: string; sessionId: string; clientSessionId?: string }>(file);
if (!record?.actorKey || !record.projectKey || !record.sessionId) continue;
const context = { actorKey: record.actorKey, projectKey: record.projectKey, sessionId: record.clientSessionId || record.sessionId };
const removed = deleted.get(context.sessionId);
if (removed) {
if (removed.actorKey !== context.actorKey || removed.projectKey !== context.projectKey) throw new Error("legacy transcript ownership mismatch");
await rm(file); continue;
}
const owner = owners.get(context.sessionId);
if (owner && (owner.actorKey !== context.actorKey || owner.projectKey !== context.projectKey)) throw new Error("legacy transcript ownership mismatch");
if (await this.copyLegacyRecord(context, "transcript.json", file)) result.transcripts++;
await this.archiveLegacyRecord(context, "transcript.json", file);
}
if (options.learningStateDir) for (const file of await listJsonFiles(options.learningStateDir)) {
const record = await readJsonFile<{ sessionId: string }>(file);
if (record && deleted.has(record.sessionId)) { await rm(file); continue; }
const owner = record && owners.get(record.sessionId);
if (!owner) continue;
if (await this.copyLegacyRecord(owner, "learning.json", file)) result.learningStates++;
await this.archiveLegacyRecord(owner, "learning.json", file);
}
await atomicWriteJson(manifestPath, { version: 1, completedAt: new Date().toISOString(), sources: await sourceStamp(), result }, 0o600);
return result;
}
async archiveLegacyRecord(context: SessionWorkspaceContext, name: ChatRecordName, source: string) {
return this.serialize(context, async () => {
if (!await this.validate(context, name)) throw new SessionContextMismatchError();
if ((await lstat(source)).isSymbolicLink()) throw new Error("legacy chat record must not be a symbolic link");
const content = await readFile(source);
const digest = createHash("sha256").update(content).digest("hex");
const archives = new SessionWorkspaceStore(this.backupRoot);
const directory = await archives.ensure(context);
const path = join(directory, "chat", `${name.replace(".json", "")}-${digest}.json`);
if (await exists(path) && (await lstat(path)).isSymbolicLink()) throw new Error("legacy backup must not be a symbolic link");
await createFileExclusively(path, (temporary) => writeFile(temporary, content, { flag: "wx", mode: 0o600 }));
if (!(await readFile(path)).equals(content) || !(await readFile(source)).equals(content)) throw new Error("legacy backup verification failed");
await rm(source);
});
}
private async copyLegacyRecord(context: SessionWorkspaceContext, name: ChatRecordName, source: string) {
return this.serialize(context, async () => {
await this.ensure(context);
await this.validate(context, name);
if (await exists(this.path(context, name))) return false;
if ((await lstat(source)).isSymbolicLink()) throw new Error("legacy chat record must not be a symbolic link");
return createFileExclusively(this.path(context, name), async (temporary) => {
await copyFile(source, temporary, constants.COPYFILE_EXCL);
await chmod(temporary, 0o600);
});
});
}
}