Files
openclaw/src/plugin-sdk/memory-host-core.ts
Peter Steinberger d6971f46ca fix: make filesystem publication crash-durable (#113453)
* fix(fs): centralize directory durability

* chore: keep release notes in PR body

* test(sqlite): canonicalize read-only race paths

* test(fs): clean durability fixtures

* chore(lint): prune sqlite snapshot baseline

* fix(backup): reject unsupported commit sync

* fix(fs): enforce strict durability outcomes

* refactor(tls): flatten preserved-output failures

* fix(reef): require durable journal commits

* fix(fs): route durability through policy boundaries

* fix(reef): preserve Windows journal compatibility

* fix(fs): bind publication to canonical directories

* fix(ci): align durability boundary fixtures
2026-07-24 20:58:17 -07:00

526 lines
19 KiB
TypeScript

/**
* Public SDK facade for memory host runtime core and public artifact discovery.
*/
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { sha256Hex, sha256HexPrefix } from "../infra/crypto-digest.js";
import { syncDirectoryIfSupported } from "../infra/directory-durability.js";
import { withFileLock } from "../infra/file-lock.js";
import { sameFileIdentity, type FileIdentityStat } from "../infra/fs-safe-advanced.js";
import { FsSafeError, root as createFsSafeRoot } from "../infra/fs-safe.js";
import {
MAX_MEMORY_HOST_PUBLIC_EXPORT_BYTES,
serializeMemoryHostEventExport,
} from "../memory-host-sdk/event-export.js";
import { listStoredMemoryHostEvents } from "../memory-host-sdk/event-store.js";
import type { MemoryPluginPublicArtifact } from "../plugins/memory-state.js";
import { KeyedAsyncQueue } from "./keyed-async-queue.js";
import { resolveMemoryDreamingWorkspaces } from "./memory-core-host-status.js";
import {
isMemoryHostEventArtifactAtIdentity,
isMissingPathError,
isRejectedWorkspaceArtifactPath,
memoryHostEventExportOwnerContent,
publishMemoryHostEventArtifact,
rewriteMemoryHostEventArtifactIfUnchanged,
} from "./memory-host-event-export.js";
const MEMORY_HOST_EVENTS_FILENAME = "memory-host-events.jsonl";
const MEMORY_HOST_EVENTS_OWNER_FILENAME = ".openclaw-memory-host-events-owner.json";
const MAX_MEMORY_HOST_PUBLIC_EXPORT_EVENTS = 1_000;
const MEMORY_HOST_EVENT_EXPORT_LOCK_OPTIONS = {
retries: { retries: 20, factor: 1.3, minTimeout: 25, maxTimeout: 250, randomize: true },
stale: 30_000,
} as const;
const memoryHostEventExportQueue = new KeyedAsyncQueue();
function isWorkspaceWriteUnavailable(error: unknown, seen = new Set<unknown>()): boolean {
if (!error || typeof error !== "object" || seen.has(error)) {
return false;
}
seen.add(error);
const code = (error as { code?: unknown }).code;
if (
code === "EACCES" ||
code === "EEXIST" ||
code === "ENOTDIR" ||
code === "EPERM" ||
code === "EROFS" ||
(error instanceof FsSafeError && (code === "not-file" || code === "not-removable"))
) {
return true;
}
if (error instanceof FsSafeError && error.category === "policy" && code !== "invalid-path") {
return false;
}
return isWorkspaceWriteUnavailable((error as { cause?: unknown }).cause, seen);
}
async function resolveMemoryHostEventExportOwner(workspaceDir: string): Promise<{
queueKey: string;
lockTarget: string;
relativePath: string;
ownerRelativePath: string;
stateHash: string;
workspaceHash: string;
}> {
const requestedStateDir = path.resolve(resolveStateDir());
await fs.mkdir(requestedStateDir, { recursive: true, mode: 0o700 });
const stateDir = await fs.realpath(requestedStateDir);
const stateHash = sha256HexPrefix(stateDir, 32);
const workspaceHash = sha256HexPrefix(path.resolve(workspaceDir), 32);
const exportDirectory = path.posix.join("memory", "events", stateHash);
return {
queueKey: `${stateHash}\0${workspaceHash}`,
lockTarget: path.join(stateDir, `.memory-host-events-export-${workspaceHash}`),
relativePath: path.posix.join(exportDirectory, MEMORY_HOST_EVENTS_FILENAME),
ownerRelativePath: path.posix.join(exportDirectory, MEMORY_HOST_EVENTS_OWNER_FILENAME),
stateHash,
workspaceHash,
};
}
async function readMemoryHostEventExportOwnership(
workspaceRoot: Awaited<ReturnType<typeof createFsSafeRoot>>,
owner: Awaited<ReturnType<typeof resolveMemoryHostEventExportOwner>>,
): Promise<
| {
kind: "owned";
content: string | undefined;
identity: FileIdentityStat;
ownerContent: string;
needsFinalize: boolean;
}
| { kind: "missing" }
| { kind: "orphan"; ownerContent: string }
| { kind: "pending-missing"; ownerContent: string }
| { kind: "foreign" }
> {
const content = await workspaceRoot.readText(owner.ownerRelativePath).catch((error: unknown) => {
if (isMissingPathError(error)) {
return undefined;
}
if (isRejectedWorkspaceArtifactPath(error)) {
return null;
}
throw error;
});
if (content === null) {
return { kind: "foreign" };
}
if (content === undefined) {
return { kind: "missing" };
}
let parsed: unknown;
try {
parsed = JSON.parse(content) as unknown;
} catch {
return { kind: "foreign" };
}
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
(parsed as { schemaVersion?: unknown }).schemaVersion !== 3 ||
(parsed as { kind?: unknown }).kind !== "openclaw-memory-host-events-export" ||
(parsed as { stateHash?: unknown }).stateHash !== owner.stateHash ||
(parsed as { workspaceHash?: unknown }).workspaceHash !== owner.workspaceHash ||
((parsed as { contentSha256?: unknown }).contentSha256 !== undefined &&
typeof (parsed as { contentSha256?: unknown }).contentSha256 !== "string") ||
((parsed as { pendingContentSha256?: unknown }).pendingContentSha256 !== undefined &&
typeof (parsed as { pendingContentSha256?: unknown }).pendingContentSha256 !== "string") ||
((parsed as { contentSha256?: unknown }).contentSha256 === undefined &&
(parsed as { pendingContentSha256?: unknown }).pendingContentSha256 === undefined) ||
((parsed as { fileDev?: unknown }).fileDev === undefined) !==
((parsed as { fileIno?: unknown }).fileIno === undefined) ||
((parsed as { fileDev?: unknown }).fileDev !== undefined &&
(typeof (parsed as { fileDev?: unknown }).fileDev !== "string" ||
!/^\d+$/u.test((parsed as { fileDev: string }).fileDev) ||
typeof (parsed as { fileIno?: unknown }).fileIno !== "string" ||
!/^\d+$/u.test((parsed as { fileIno: string }).fileIno)))
) {
return { kind: "foreign" };
}
const storedIdentity =
typeof (parsed as { fileDev?: unknown }).fileDev === "string" &&
typeof (parsed as { fileIno?: unknown }).fileIno === "string"
? {
dev: BigInt((parsed as { fileDev: string }).fileDev),
ino: BigInt((parsed as { fileIno: string }).fileIno),
}
: undefined;
let openedExport: Awaited<ReturnType<typeof workspaceRoot.open>> | undefined;
try {
openedExport = await workspaceRoot.open(owner.relativePath);
} catch (error) {
if (isMissingPathError(error)) {
openedExport = undefined;
} else if (isRejectedWorkspaceArtifactPath(error)) {
return { kind: "foreign" };
} else {
throw error;
}
}
if (!openedExport) {
return typeof (parsed as { pendingContentSha256?: unknown }).pendingContentSha256 === "string"
? { kind: "pending-missing", ownerContent: content }
: { kind: "orphan", ownerContent: content };
}
let exportContent: string;
const exportIdentity: FileIdentityStat = {
dev: openedExport.stat.dev,
ino: openedExport.stat.ino,
};
const identityOwned =
storedIdentity !== undefined && sameFileIdentity(storedIdentity, exportIdentity);
try {
if (openedExport.stat.size > MAX_MEMORY_HOST_PUBLIC_EXPORT_BYTES) {
return identityOwned
? {
kind: "owned",
content: undefined,
identity: exportIdentity,
ownerContent: content,
needsFinalize: true,
}
: { kind: "foreign" };
}
exportContent = await openedExport.handle.readFile({ encoding: "utf8" });
} finally {
await openedExport.handle.close().catch(() => undefined);
}
const exportSha256 = sha256Hex(exportContent);
const currentSha256 = (parsed as { contentSha256?: string }).contentSha256;
const pendingSha256 = (parsed as { pendingContentSha256?: string }).pendingContentSha256;
// Hash-only markers never reach the owned branch; only the persisted inode
// identity authorizes later mutation of this workspace artifact.
return identityOwned
? {
kind: "owned",
content: exportContent,
identity: exportIdentity,
ownerContent: content,
needsFinalize: exportSha256 !== currentSha256 || pendingSha256 !== undefined,
}
: { kind: "foreign" };
}
export {
buildMemoryPromptSection as buildActiveMemoryPromptSection,
clearMemoryPluginState,
getMemoryCapabilityRegistration,
listActiveMemoryPublicArtifacts,
registerMemoryCapability,
registerMemoryCorpusSupplement,
} from "../plugins/memory-state.js";
export type {
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPromptSectionBuilder,
} from "../plugins/memory-state.js";
export { resolveDefaultAgentId } from "../agents/agent-scope-config.js";
export { resolveSessionAgentId } from "../agents/agent-scope.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listMarkdownFilesRecursive(fullPath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
async function materializeMemoryHostEventExport(params: {
workspaceDir: string;
}): Promise<{ absolutePath: string; relativePath: string } | undefined> {
const requestedWorkspace = path.resolve(params.workspaceDir);
const workspace = await fs.stat(requestedWorkspace).catch((error: unknown) => {
if (isMissingPathError(error)) {
return undefined;
}
throw error;
});
if (!workspace?.isDirectory()) {
return undefined;
}
const workspaceRoot = await createFsSafeRoot(requestedWorkspace, {
hardlinks: "reject",
mkdir: true,
mode: 0o600,
symlinks: "reject",
});
const workspaceKey = workspaceRoot.rootReal;
const owner = await resolveMemoryHostEventExportOwner(workspaceKey);
// The queue handles re-entrant calls in this process; the sidecar lock makes
// snapshot, cleanup, and replacement one ordered operation across processes.
// State-qualified paths keep different profiles from replacing each other's export.
return memoryHostEventExportQueue.enqueue(owner.queueKey, async () => {
const absolutePath = path.join(workspaceKey, ...owner.relativePath.split("/"));
return await withFileLock(owner.lockTarget, MEMORY_HOST_EVENT_EXPORT_LOCK_OPTIONS, async () => {
const storedEvents = listStoredMemoryHostEvents({
workspaceDir: workspaceKey,
limit: MAX_MEMORY_HOST_PUBLIC_EXPORT_EVENTS,
});
const ownership = await readMemoryHostEventExportOwnership(workspaceRoot, owner);
if (ownership.kind === "foreign") {
return undefined;
}
if (storedEvents.length === 0 && ownership.kind !== "owned") {
return undefined;
}
const content = storedEvents.length > 0 ? serializeMemoryHostEventExport(storedEvents) : "";
const contentSha256 = sha256Hex(content);
let publishedIdentity: FileIdentityStat | undefined;
if (ownership.kind === "missing") {
const existing = await workspaceRoot
.readText(owner.relativePath)
.catch((error: unknown) => {
if (isMissingPathError(error)) {
return undefined;
}
if (isRejectedWorkspaceArtifactPath(error)) {
return null;
}
throw error;
});
if (existing !== undefined) {
return undefined;
}
try {
const pendingOwnerContent = memoryHostEventExportOwnerContent(owner, {
pendingSha256: contentSha256,
});
await workspaceRoot.create(owner.ownerRelativePath, pendingOwnerContent, {
mkdir: true,
mode: 0o600,
});
await syncDirectoryIfSupported(path.dirname(absolutePath));
publishedIdentity = await publishMemoryHostEventArtifact({
workspaceRoot,
owner,
absolutePath,
expectedOwnerContent: pendingOwnerContent,
content,
contentSha256,
});
if (!publishedIdentity) {
return undefined;
}
} catch (error) {
if (isWorkspaceWriteUnavailable(error)) {
return undefined;
}
throw error;
}
} else if (ownership.kind === "pending-missing" || ownership.kind === "orphan") {
try {
const pendingOwnerContent = memoryHostEventExportOwnerContent(owner, {
pendingSha256: contentSha256,
});
if (
!(await rewriteMemoryHostEventArtifactIfUnchanged({
workspaceRoot,
relativePath: owner.ownerRelativePath,
expectedContent: ownership.ownerContent,
nextContent: pendingOwnerContent,
}))
) {
return undefined;
}
await syncDirectoryIfSupported(path.dirname(absolutePath));
publishedIdentity = await publishMemoryHostEventArtifact({
workspaceRoot,
owner,
absolutePath,
expectedOwnerContent: pendingOwnerContent,
content,
contentSha256,
});
if (!publishedIdentity) {
return undefined;
}
} catch (error) {
if (isWorkspaceWriteUnavailable(error)) {
return undefined;
}
throw error;
}
} else if (ownership.content !== content) {
publishedIdentity = ownership.identity;
try {
const updateOwnerContent = memoryHostEventExportOwnerContent(owner, {
pendingSha256: contentSha256,
identity: ownership.identity,
...(ownership.content === undefined
? {}
: { currentSha256: sha256Hex(ownership.content) }),
});
const currentOwnerContent = memoryHostEventExportOwnerContent(owner, {
currentSha256: contentSha256,
identity: ownership.identity,
});
if (
!(await rewriteMemoryHostEventArtifactIfUnchanged({
workspaceRoot,
relativePath: owner.ownerRelativePath,
expectedContent: ownership.ownerContent,
nextContent: updateOwnerContent,
}))
) {
return undefined;
}
await syncDirectoryIfSupported(path.dirname(absolutePath));
if (
!(await rewriteMemoryHostEventArtifactIfUnchanged({
workspaceRoot,
relativePath: owner.relativePath,
expectedIdentity: ownership.identity,
nextContent: content,
}))
) {
return undefined;
}
await syncDirectoryIfSupported(path.dirname(absolutePath));
if (
!(await rewriteMemoryHostEventArtifactIfUnchanged({
workspaceRoot,
relativePath: owner.ownerRelativePath,
expectedContent: updateOwnerContent,
nextContent: currentOwnerContent,
}))
) {
return undefined;
}
await syncDirectoryIfSupported(path.dirname(absolutePath));
} catch (error) {
if (isWorkspaceWriteUnavailable(error)) {
return undefined;
}
throw error;
}
} else if (ownership.needsFinalize) {
publishedIdentity = ownership.identity;
try {
if (
!(await rewriteMemoryHostEventArtifactIfUnchanged({
workspaceRoot,
relativePath: owner.ownerRelativePath,
expectedContent: ownership.ownerContent,
nextContent: memoryHostEventExportOwnerContent(owner, {
currentSha256: contentSha256,
identity: ownership.identity,
}),
}))
) {
return undefined;
}
await syncDirectoryIfSupported(path.dirname(absolutePath));
} catch (error) {
if (isWorkspaceWriteUnavailable(error)) {
return undefined;
}
throw error;
}
} else {
publishedIdentity = ownership.identity;
}
if (storedEvents.length === 0 || !publishedIdentity) {
return undefined;
}
return (await isMemoryHostEventArtifactAtIdentity({
workspaceRoot,
relativePath: owner.relativePath,
expectedIdentity: publishedIdentity,
expectedContent: content,
}))
? { absolutePath, relativePath: owner.relativePath }
: undefined;
});
});
}
/** Lists public memory artifacts for one workspace, including notes and event logs. */
async function listMemoryWorkspacePublicArtifacts(params: {
workspaceDir: string;
agentIds: string[];
}): Promise<MemoryPluginPublicArtifact[]> {
const artifacts: MemoryPluginPublicArtifact[] = [];
const workspaceEntries = new Set(
(await fs.readdir(params.workspaceDir, { withFileTypes: true }).catch(() => []))
.filter((entry) => entry.isFile())
.map((entry) => entry.name),
);
if (workspaceEntries.has("MEMORY.md")) {
const absolutePath = path.join(params.workspaceDir, "MEMORY.md");
artifacts.push({
kind: "memory-root",
workspaceDir: params.workspaceDir,
relativePath: "MEMORY.md",
absolutePath,
agentIds: [...params.agentIds],
contentType: "markdown",
});
}
const memoryDir = path.join(params.workspaceDir, "memory");
for (const absolutePath of await listMarkdownFilesRecursive(memoryDir)) {
const relativePath = path.relative(params.workspaceDir, absolutePath).replace(/\\/g, "/");
artifacts.push({
kind: relativePath.startsWith("memory/dreaming/") ? "dream-report" : "daily-note",
workspaceDir: params.workspaceDir,
relativePath,
absolutePath,
agentIds: [...params.agentIds],
contentType: "markdown",
});
}
const eventExport = await materializeMemoryHostEventExport({
workspaceDir: params.workspaceDir,
});
if (eventExport) {
artifacts.push({
kind: "event-log",
workspaceDir: params.workspaceDir,
relativePath: eventExport.relativePath,
absolutePath: eventExport.absolutePath,
agentIds: [...params.agentIds],
contentType: "json",
});
}
const deduped = new Map<string, MemoryPluginPublicArtifact>();
for (const artifact of artifacts) {
deduped.set(`${artifact.workspaceDir}\0${artifact.relativePath}\0${artifact.kind}`, artifact);
}
return [...deduped.values()];
}
/** Lists public memory artifacts across all configured memory workspaces. */
export async function listMemoryHostPublicArtifacts(params: {
cfg: OpenClawConfig;
}): Promise<MemoryPluginPublicArtifact[]> {
const workspaces = resolveMemoryDreamingWorkspaces(params.cfg);
const artifacts: MemoryPluginPublicArtifact[] = [];
for (const workspace of workspaces) {
artifacts.push(
...(await listMemoryWorkspacePublicArtifacts({
workspaceDir: workspace.workspaceDir,
agentIds: workspace.agentIds,
})),
);
}
return artifacts;
}