Support explicit read-only RAG archives
This commit is contained in:
parent
b3191f4f2c
commit
bba41efbd6
6 changed files with 36 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue