openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
144 lines (143 loc) • 6.97 kB
JavaScript
import { t as hasNonEmptyString } from "./string-coerce-1VuiLaaR.mjs";
import { _ as resolveDeliveryQueueMediaDir } from "./paths-DGIwWbpu.mjs";
import { a as generateSecureUuid } from "./secure-random-Dimnnrm0.mjs";
import { t as captureDeliveryQueueStateContext } from "./delivery-queue-state-context-BiGOf1E1.mjs";
import "./delivery-queue-sqlite-CZH3GILC.mjs";
import { t as isPassThroughRemoteMediaSource } from "./media-source-url-BdNs7xa2.mjs";
import { i as spoolRelativePath, n as collectEntrySpoolPaths, r as payloadMediaSources, t as ARTIFACT_NAME_RE } from "./delivery-queue-media-paths-CYBc7PO4.mjs";
import { t as buildOutboundMediaLoadOptions } from "./load-options-gEuoEu4c.mjs";
import { n as loadWebMedia } from "./web-media-DIUZpYeK.mjs";
import { n as createDeliveryQueueMediaRetention, r as loadDeliveryQueueMediaRetentionSnapshot, t as cancelDeliveryQueueMediaRetention } from "./delivery-queue-media-staging-DXj5mTxE.mjs";
import path from "node:path";
import fs from "node:fs/promises";
import { fileStore } from "@openclaw/fs-safe/store";
//#region src/infra/outbound/delivery-queue-media-spool.ts
const ARTIFACT_EXT_RE = /^\.[A-Za-z0-9]{1,10}$/;
const PART_SUFFIX = ".part";
const ORPHAN_GRACE_MS = 864e5;
function openSpoolStore(stateDir, maxBytes) {
return fileStore({
rootDir: resolveDeliveryQueueMediaDir(stateDir),
dirMode: 448,
mode: 384,
maxBytes
});
}
function resolveArtifactExtension(source) {
const extension = path.extname(source.split("?")[0] ?? "");
return ARTIFACT_EXT_RE.test(extension) ? extension.toLowerCase() : "";
}
/** Remote and data sources carry their own bytes; only local paths need queue custody. */
function isSpoolableSource(source) {
return !isPassThroughRemoteMediaSource(source) && !/^data:/i.test(source);
}
/**
* Copies local media into queue custody and rewrites only the queue payloads.
* The same loader and capability as the live send authorize every source.
*/
async function stageQueuePayloadMedia(params, context) {
const stateDir = context?.stateDir ?? params.stateDir;
if (params.payloads.some((payload) => payload.sensitiveMedia === true && payloadMediaSources(payload).length > 0)) return {
status: "not-durable",
reason: "sensitive-media"
};
const spoolRoot = path.resolve(resolveDeliveryQueueMediaDir(stateDir));
const artifactPrefix = params.artifactFormat === "command-owner-v1" ? "c1-" : params.artifactFormat === "session-generation-v1" ? "g1-" : "";
const artifactsBySource = /* @__PURE__ */ new Map();
for (const source of params.payloads.flatMap(payloadMediaSources)) if (isSpoolableSource(source) && !artifactsBySource.has(source)) artifactsBySource.set(source, path.join(spoolRoot, `${artifactPrefix}${generateSecureUuid()}${resolveArtifactExtension(source)}`));
const artifacts = [...artifactsBySource.values()];
const mediaStageId = artifacts.length > 0 ? await createDeliveryQueueMediaRetention(artifacts, "outbound-media-stage", stateDir, context) : void 0;
const store = openSpoolStore(stateDir, params.maxBytes);
const publishedSources = /* @__PURE__ */ new Set();
const stageSource = async (source) => {
const stagedPath = artifactsBySource.get(source);
if (!stagedPath) throw new Error(`Delivery queue media source was not planned: ${source}`);
if (publishedSources.has(source)) return stagedPath;
const media = await loadWebMedia(source, buildOutboundMediaLoadOptions({
maxBytes: params.maxBytes,
mediaAccess: params.mediaAccess,
mediaLocalRoots: params.mediaAccess?.localRoots,
mediaReadFile: params.mediaAccess?.readFile
}));
const finalRelative = path.basename(stagedPath);
const partRelative = `${finalRelative}${PART_SUFFIX}`;
try {
await store.write(partRelative, media.buffer, { maxBytes: params.maxBytes });
await (await store.root()).move(partRelative, finalRelative, { overwrite: false });
publishedSources.add(source);
} catch (err) {
await store.remove(partRelative).catch(() => void 0);
throw err;
}
return stagedPath;
};
const stagedPayloads = [];
try {
for (const payload of params.payloads) {
if (payloadMediaSources(payload).filter(isSpoolableSource).length === 0) {
stagedPayloads.push(payload);
continue;
}
const staged = { ...payload };
if (hasNonEmptyString(payload.mediaUrl) && isSpoolableSource(payload.mediaUrl)) staged.mediaUrl = await stageSource(payload.mediaUrl);
if (payload.mediaUrls) {
const stagedMediaUrls = [];
for (const mediaUrl of payload.mediaUrls) stagedMediaUrls.push(hasNonEmptyString(mediaUrl) && isSpoolableSource(mediaUrl) ? await stageSource(mediaUrl) : mediaUrl);
staged.mediaUrls = stagedMediaUrls;
}
stagedPayloads.push(staged);
}
} catch (err) {
await cancelDeliveryQueueMediaRetention(mediaStageId, stateDir, context);
await releaseSpoolArtifacts(artifacts, stateDir);
throw err;
}
return {
status: "staged",
payloads: stagedPayloads,
artifacts,
...mediaStageId ? { mediaStageId } : {}
};
}
async function removeArtifact(absolutePath, stateDir) {
const relative = spoolRelativePath(absolutePath, stateDir);
if (!relative) return;
try {
await openSpoolStore(stateDir).remove(relative);
} catch {}
}
/** Discards spool artifacts whose durable row is already gone. Never throws. */
async function releaseSpoolArtifacts(artifacts, stateDir) {
for (const artifact of artifacts) await removeArtifact(artifact, stateDir);
}
/**
* Removes old unreferenced spool files. Pending-row references always win over
* age; the grace covers the stage-before-row-commit crash window and bounds all
* final and partial artifacts that never acquire a row.
*/
async function pruneOrphanedDeliveryQueueMedia(params, context) {
const captured = context ?? captureDeliveryQueueStateContext(params?.stateDir);
const stateDir = captured.stateDir;
const cutoffMs = (params?.nowMs ?? Date.now()) - ORPHAN_GRACE_MS;
const snapshot = await loadDeliveryQueueMediaRetentionSnapshot({ expireBeforeMs: cutoffMs }, captured);
const spoolRoot = path.resolve(resolveDeliveryQueueMediaDir(stateDir));
const retainPaths = new Set(snapshot.stagedArtifacts.concat(snapshot.payloads.flatMap((payloads) => collectEntrySpoolPaths(payloads, stateDir))).map((entry) => path.resolve(entry)));
const entries = await fs.readdir(spoolRoot, { withFileTypes: true }).catch((err) => {
if (err.code === "ENOENT") return null;
throw err;
});
if (!entries) return;
for (const entry of entries) {
if (!entry.isFile() || !ARTIFACT_NAME_RE.test(entry.name)) continue;
const artifactPath = path.join(spoolRoot, entry.name);
if (retainPaths.has(artifactPath)) continue;
const stats = await fs.stat(artifactPath).catch((err) => {
if (err.code === "ENOENT") return null;
throw err;
});
if (!stats || stats.mtimeMs > cutoffMs) continue;
await removeArtifact(artifactPath, stateDir);
}
}
//#endregion
export { releaseSpoolArtifacts as n, stageQueuePayloadMedia as r, pruneOrphanedDeliveryQueueMedia as t };