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:
+108
-24
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user