Support explicit read-only RAG archives

This commit is contained in:
Meta Vens 2026-08-16 19:40:14 +02:00
commit bba41efbd6
6 changed files with 36 additions and 8 deletions

View file

@ -26,8 +26,14 @@ export function createVoidbotMcpContext(): VoidbotMcpContext {
process.chdir(workspaceRoot);
const config = loadConfig();
const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath);
const sourceArchiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath);
const archiveOptions = {
readOnly: process.env.VOIDBOT_MCP_ARCHIVES_READ_ONLY === "true",
};
const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath, archiveOptions);
const sourceArchiveRepository = new FileSourceDocumentArchiveRepository(
config.ragSourceArchivePath,
archiveOptions,
);
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,

View file

@ -352,3 +352,7 @@ standalone `apps/worker/src/mcp-http-main.ts` entrypoint reuses the canonical
MCP context, factory, archive repositories, vector stores, and HTTP transport.
Its deployment allow-list contains retrieval tools only. It owns no Discord,
job, Persona, moderation, Bifrost, Idunn, or state-mutation authority.
`VOIDBOT_MCP_ARCHIVES_READ_ONLY=true` is an explicit storage-port mode:
snapshots read without creating writer locks, while overwrite, mutation, and
normalization fail before touching the filesystem. Writable runtimes retain
the existing interprocess lock path.

View file

@ -7,20 +7,27 @@ export class SerializedFileStore<T> {
public constructor(
private readonly filePath: string,
private readonly createDefault: () => T,
private readonly options: { readOnly?: boolean } = {},
) {}
public async snapshot(): Promise<T> {
const store = await this.serialize(() => this.withInterprocessLock(() => this.readUnlocked()));
const store = await this.serialize(() =>
this.options.readOnly
? this.readUnlocked()
: this.withInterprocessLock(() => this.readUnlocked()),
);
return structuredClone(store);
}
public async overwrite(nextValue: T): Promise<void> {
this.requireWritable();
await this.serialize(async () => {
await this.withInterprocessLock(() => this.writeUnlocked(nextValue));
});
}
public async mutate<R>(mutator: (current: T) => R | Promise<R>): Promise<R> {
this.requireWritable();
return this.serialize(async () => {
return this.withInterprocessLock(async () => {
const current = await this.readUnlocked();
@ -32,6 +39,7 @@ export class SerializedFileStore<T> {
}
public async normalize(): Promise<void> {
this.requireWritable();
await this.serialize(async () => {
await this.withInterprocessLock(async () => {
const current = await this.readUnlocked();
@ -49,6 +57,12 @@ export class SerializedFileStore<T> {
return pending;
}
private requireWritable(): void {
if (this.options.readOnly) {
throw new Error(`Read-only file store cannot mutate: ${this.filePath}`);
}
}
private async readUnlocked(): Promise<T> {
try {
const raw = await readFile(this.filePath, "utf8");

View file

@ -56,11 +56,11 @@ interface HistoryArchiveFilters
export class FileMessageArchiveRepository {
private readonly store: SerializedFileStore<MessageArchiveStore>;
public constructor(filePath: string) {
public constructor(filePath: string, options: { readOnly?: boolean } = {}) {
this.store = new SerializedFileStore(filePath, () => ({
version: 1,
messages: [],
}));
}), options);
}
public async upsert(messages: ArchivedMessage[]): Promise<MessageArchiveMutationResult> {

View file

@ -57,11 +57,14 @@ export class FileSourceDocumentArchiveRepository {
private readonly shardRoot: string;
private initialization?: Promise<void>;
public constructor(private readonly filePath: string) {
public constructor(
private readonly filePath: string,
private readonly options: { readOnly?: boolean } = {},
) {
this.manifestStore = new SerializedFileStore(filePath, () => ({
version: 2,
repos: [],
}));
}), options);
this.shardRoot = deriveShardRoot(filePath);
}
@ -277,7 +280,7 @@ export class FileSourceDocumentArchiveRepository {
version: 1,
repoName,
documents: [],
}));
}), this.options);
}
private async syncRepoSummary(repoName: string, documentCount: number): Promise<void> {

View file

@ -460,3 +460,4 @@
{"ts":"2026-07-19T14:25:00Z","type":"architecture","status":"ok","note":"The per-call SSH MCP relay was identified as a transport compensator and deleted. The resident Yggdrasil worker now owns a loopback streamable-HTTP MCP organ injected with its existing archive repositories, source ingester, and retrieval service. The canonical workstation tunnel owns the sole Starfire crossing at 127.0.0.1:17875; Codex uses the URL and tool calls start no processes or alternate retrieval authorities."}
{"ts":"2026-07-19T20:10:00Z","type":"repair","status":"ok","note":"Direct Codex streamable-HTTP registration still closed permanently after a brief Idunn worker deployment or SSH-tunnel gap; stateless server sessions did not imply client transport recovery. Codex now attaches to a task-local stdio continuity bridge with no retrieval authority. Each tool call uses a lightweight HTTP client to the resident daemon, and the bridge lowers endpoint failure to a tool error. A single outer MCP client succeeded before a forced endpoint outage, received isError during the outage, and succeeded again after the same endpoint returned."}
{"ts":"2026-08-16T17:30:00Z","type":"recovery","status":"ok","note":"Big Yggdrasil was retired after an incomplete evacuation and its recycled 94.130.223.131 address is not trusted. Old Yggdrasil retained no VoidBot state, but Raven held a verified June 3 offsite archive (SHA-256 47572df766e63212012eb75762359113a11c8bbf149b497e120fc4fda7191dfc); the June 4 archive is corrupt. Recovery is intentionally bounded to a loopback read-only MCP over exact archives and VoidBot-owned vectors while lost Discord, Idunn, Odin, Epiphany, Persona, moderation, and delivery authority remains stopped."}
{"ts":"2026-08-16T17:40:00Z","type":"architecture","status":"ok","note":"Read-only MCP recovery exposed that SerializedFileStore snapshots always created writer locks. Archive repositories now accept an explicit read-only mode: snapshots read without filesystem locks and every mutation method fails immediately. A Node 24 build and a nobody-user probe on a non-writable directory proved snapshot success plus mutation rejection; writable runtimes retain interprocess locking."}