mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip manages work done by AI agents. > - Native sandbox runs prepare a workspace before the agent can respond. > - A shared seed cache avoids repeated Git copies and archive preparation. > - Cache publication also flushes a branch-neutral Git seed that is separate from recovery archives. > - This cache can be rebuilt. Each reader checks the archive bytes before reuse. > - This change omits that separate cache file flush and measures the remaining preparation gaps. > - The benefit is less work before agent execution and clearer timing evidence. ## Linked Issues or Issue Description Refs #18240. **What existing behavior does this improve?** Workspace seed cache publication and latency diagnostics for native sandbox preparation. **Current behavior** Publication flushes both cache archives to disk under the company lock. Slow storage can delay the publishing run and cache readers. A cache hit also combines private copying, hashing, and last-used bookkeeping in one span. Early controller preparation has gaps before environment selection. **Proposed behavior** Keep the existing flush for recovery-backed workspace and Git archives. Omit the flush only for a separate branch-neutral Git cache archive. Reject an alternate cache file that is the recovery file or its hardlink. Keep archive checksums, private copies, atomic publication, quotas, and company locking. Measure cache read steps and the existing early preparation operations. ## What Changed - Skip the flush only for the separate branch-neutral Git seed used by the company cache. - Preserve the existing flush for recovery-backed archives, including hardlink storage. Reject cache-only files that alias recovery bytes before or during publication. - Add a regression with a blocked cache-only Git flush. The deployed code waits; the new code publishes verified cache bytes without waiting. Test storage failure for both recovery-backed archives and same-path, hardlink, and replacement alias cases. - Add cache copy, hash, and bookkeeping spans beneath seed lookup. - Add timing around existing agent, conversation, runtime, issue, identity, trust, and task context preparation. Keep their execution order. - Update the trust-order guard to recognize the timed call while continuing to assert trust resolution precedes workspace selection and credential setup. - Document the cache contract and the new spans. Keep the old flush span in the allowlist for compatibility with older observations. ## Verification - The blocked-flush regression fails on the old code and passes with this change. - Focused tests (`vitest run` on the six affected cache, timing, and native-sync suites) cover cache corruption, private-copy integrity, quota and eviction races, branch binding, per-run recovery, native workspace synchronization, trace parentage, and tracing disabled or failing. - 115 focused tests and both typechecks pass after rebase on current master. The tests include recovery storage failures and an archive replaced with a recovery hardlink during verification. - All seven trust tests pass with the actual service and migrations, using a fresh database on an existing local PostgreSQL server. The local embedded server could not allocate host semaphores, so only its test startup helper was substituted; assertions and service code were unchanged. - A real SDK export with a retained public seed verifies that cache-read detail spans have the lookup as their parent. The endpoint-off control produces no spans. - Current revision `28dea63432d462cad160906f8dfc2d21c81d3446`: all 53 checks pass, two optional checks are skipped, Greptile is 5/5, and no review threads are open. - No new staging latency claim is made by this PR. Hosted measurement remains necessary after delivery. ## Risks A crash or power loss can discard the cache-only Git seed. A later read rejects missing or corrupt cache bytes and prepares the workspace again. Recovery-backed archives retain their previous flush behavior. The change adds no flush to warm per-run persistence. Hashes and private-copy checks remain required. The new spans use the existing opt-in exporter and closed names; they record no task content. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use, and code execution. The session does not expose the exact model SKU or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
299 lines
18 KiB
TypeScript
299 lines
18 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { constants, createReadStream } from "node:fs";
|
|
import { directorySnapshotSha256, withDirectoryMergeLock, type DirectorySnapshot } from "./workspace-restore-merge.js";
|
|
import { assertWorkspaceManifestDiskSpace } from "./workspace-manifest.js";
|
|
import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js";
|
|
import type { WorkspaceDurableSeedPaths } from "./sandbox-managed-runtime.js";
|
|
import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
|
|
|
|
export function workspaceSeedGeneration(baseline: DirectorySnapshot, git: GitWorkspaceSnapshot | null, repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [], scope: "workspace" | "company" = "workspace"): string {
|
|
const identity = (snapshot: GitWorkspaceSnapshot | null): unknown => snapshot && ({
|
|
head: snapshot.headCommit, ...(scope === "workspace" ? { branch: snapshot.branchName } : {}), origin: snapshot.originUrl,
|
|
repositories: snapshot.repositories?.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) })),
|
|
});
|
|
return createHash("sha256").update(scope === "company" ? "verified-company-seed-v1\0" : "verified-seed-v1\0").update(JSON.stringify([directorySnapshotSha256(baseline), identity(git), repositories.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) }))])).digest("hex");
|
|
}
|
|
|
|
export function workspaceSeedCloneSnapshot(snapshot: GitWorkspaceSnapshot): GitWorkspaceSnapshot {
|
|
return { ...snapshot, branchName: null,
|
|
...(snapshot.repositories ? { repositories: snapshot.repositories.map(repository => ({ ...repository, snapshot: workspaceSeedCloneSnapshot(repository.snapshot) })) } : {}),
|
|
};
|
|
}
|
|
async function digest(file: string): Promise<string> {
|
|
const hash = createHash("sha256");
|
|
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
/** A privateCopyDirectory must be caller-owned staging; its caller owns cleanup. */
|
|
export async function readWorkspaceSeedGeneration(root: string, generation: string,
|
|
options: { companyDirectory?: string; privateCopyDirectory?: string; runtimeSpan?: RuntimeSpanRunner } = {}): Promise<WorkspaceDurableSeedPaths | null> {
|
|
const measure = <T>(name: string, work: () => Promise<T>): Promise<T> =>
|
|
options.runtimeSpan ? options.runtimeSpan(name, work) : work();
|
|
const directory = path.join(root, generation);
|
|
let privateCopy: string | null = null;
|
|
let accepted = false;
|
|
try {
|
|
for (const item of [root, directory]) { const stat = await fs.lstat(item); if (!stat.isDirectory() || stat.isSymbolicLink()) return null; }
|
|
const receipt = path.join(directory, "receipt.json");
|
|
const receiptStat = await fs.lstat(receipt);
|
|
if (!receiptStat.isFile() || receiptStat.isSymbolicLink() || receiptStat.size > 4096) return null;
|
|
const metadata = JSON.parse(await fs.readFile(receipt, "utf8"));
|
|
if (!/^[a-f0-9]{64}$/.test(metadata.workspaceSha256) || (metadata.gitSha256 !== null && !/^[a-f0-9]{64}$/.test(metadata.gitSha256))) return null;
|
|
if (metadata.version !== 1 || metadata.generation !== generation) return null;
|
|
if (options.privateCopyDirectory) {
|
|
const stat = await fs.lstat(options.privateCopyDirectory);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
privateCopy = await fs.mkdtemp(path.join(options.privateCopyDirectory, "verified-seed-"));
|
|
}
|
|
const archiveDirectory = privateCopy ?? directory;
|
|
const workspaceArchivePath = path.join(archiveDirectory, "workspace.tar");
|
|
const gitArchivePath = metadata.gitSha256 ? path.join(archiveDirectory, "git.tar") : null;
|
|
for (const [file, expected] of [[workspaceArchivePath, metadata.workspaceSha256], [gitArchivePath, metadata.gitSha256]]) {
|
|
if (!file) continue;
|
|
const source = path.join(directory, path.basename(file));
|
|
const stat = await fs.lstat(source);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
// Hash the exact private bytes staging will consume. This avoids a
|
|
// second source hash and remains valid if the shared cache changes later.
|
|
if (privateCopy) await measure("workspace.seed_read_copy", () => fs.copyFile(source, file, constants.COPYFILE_FICLONE | constants.COPYFILE_EXCL));
|
|
if (await measure("workspace.seed_read_hash", () => digest(file)) !== expected) return null;
|
|
}
|
|
const used = await measure("workspace.seed_read_touch", () => withDirectoryMergeLock(path.resolve(options.companyDirectory ?? root), async () => {
|
|
const current = await fs.lstat(receipt).catch(() => null);
|
|
if (!current || current.ino !== receiptStat.ino || current.dev !== receiptStat.dev) return false;
|
|
const usedAt = new Date();
|
|
await fs.utimes(receipt, usedAt, usedAt);
|
|
return true;
|
|
}));
|
|
if (!used) return null;
|
|
accepted = true;
|
|
return { workspaceArchivePath, workspaceArchiveSha256: metadata.workspaceSha256,
|
|
gitArchivePath, gitArchiveSha256: metadata.gitSha256 };
|
|
} catch { return null; }
|
|
finally { if (!accepted && privateCopy) await fs.rm(privateCopy, { recursive: true, force: true }).catch(() => undefined); }
|
|
}
|
|
export const WORKSPACE_SEED_CACHE_LIMITS = Object.freeze({
|
|
workspaceGenerations: 16,
|
|
workspaceBytes: 1024 ** 3,
|
|
companyGenerations: 256,
|
|
companyWorkspaces: 256,
|
|
companyBytes: 8 * 1024 ** 3,
|
|
});
|
|
type SeedCacheLimits = { [Key in keyof typeof WORKSPACE_SEED_CACHE_LIMITS]: number };
|
|
|
|
export const WORKSPACE_SEED_CACHE_EVICTION = Object.freeze({
|
|
idleMs: 7 * 24 * 60 * 60 * 1000,
|
|
minIdleMs: 60 * 60 * 1000,
|
|
});
|
|
type SeedCacheEviction = { [Key in keyof typeof WORKSPACE_SEED_CACHE_EVICTION]: number };
|
|
|
|
type CachedGeneration = { directory: string; workspace: string; bytes: number; usedAt: number; evictable: boolean };
|
|
|
|
async function removeCacheEntry(directory: string) {
|
|
const retired = path.join(path.dirname(directory), `.evicted-${randomUUID()}`);
|
|
await fs.rename(directory, retired);
|
|
await fs.rm(retired, { recursive: true, force: true });
|
|
}
|
|
|
|
async function cachedGenerations(company: string, root: string, cleanup: boolean): Promise<{ workspaces: string[]; generations: CachedGeneration[] }> {
|
|
const workspaces = root === company ? [company]
|
|
: (await fs.readdir(company)).map(name => path.join(company, name));
|
|
const generations: CachedGeneration[] = [];
|
|
for (const workspace of workspaces) {
|
|
const workspaceStat = await fs.lstat(workspace).catch(() => null);
|
|
if (!workspaceStat?.isDirectory() || workspaceStat.isSymbolicLink()) continue;
|
|
for (const name of await fs.readdir(workspace)) {
|
|
const directory = path.join(workspace, name);
|
|
if (name.startsWith(".evicted-")) { if (cleanup) await fs.rm(directory, { recursive: true, force: true }); continue; }
|
|
const stat = await fs.lstat(directory).catch(() => null);
|
|
if (!stat) continue;
|
|
const evictable = /^[a-f0-9]{64}$/.test(name) && stat.isDirectory() && !stat.isSymbolicLink();
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) { generations.push({ directory, workspace, bytes: stat.size, usedAt: stat.mtimeMs, evictable }); continue; }
|
|
let bytes = 0, usedAt = stat.mtimeMs;
|
|
for (const child of await fs.readdir(directory)) {
|
|
const file = await fs.lstat(path.join(directory, child)).catch(() => null);
|
|
if (!file?.isFile()) continue;
|
|
bytes += file.size;
|
|
if (child === "receipt.json") usedAt = file.mtimeMs;
|
|
}
|
|
generations.push({ directory, workspace, bytes, usedAt, evictable });
|
|
}
|
|
}
|
|
return { workspaces, generations };
|
|
}
|
|
|
|
async function evictSeedGenerations(company: string, root: string, input: {
|
|
generation: string; incomingBytes: number; limits: SeedCacheLimits; eviction: SeedCacheEviction; now: number; apply: boolean;
|
|
}): Promise<boolean> {
|
|
const { workspaces: workspaceDirectories, generations } = await cachedGenerations(company, root, input.apply);
|
|
const remove = async (directory: string) => { if (input.apply) await removeCacheEntry(directory); };
|
|
const target = path.join(root, input.generation);
|
|
const live: CachedGeneration[] = [];
|
|
for (const entry of generations) {
|
|
if (entry.evictable && entry.directory !== target && entry.usedAt < input.now - input.eviction.idleMs) await remove(entry.directory);
|
|
else live.push(entry);
|
|
}
|
|
const over = () => {
|
|
const inWorkspace = live.filter(entry => entry.workspace === root);
|
|
const workspaces = new Set([...live.map(entry => entry.workspace), root]).size;
|
|
return {
|
|
workspace: inWorkspace.length + 1 > input.limits.workspaceGenerations
|
|
|| inWorkspace.reduce((sum, entry) => sum + entry.bytes, 0) + input.incomingBytes > input.limits.workspaceBytes,
|
|
company: live.length + 1 > input.limits.companyGenerations
|
|
|| live.reduce((sum, entry) => sum + entry.bytes, 0) + input.incomingBytes > input.limits.companyBytes
|
|
|| workspaces > input.limits.companyWorkspaces,
|
|
};
|
|
};
|
|
for (let pressure = over(); pressure.workspace || pressure.company; pressure = over()) {
|
|
const victim = live
|
|
.filter(entry => entry.evictable && entry.directory !== target && entry.usedAt < input.now - input.eviction.minIdleMs
|
|
&& (pressure.company || entry.workspace === root))
|
|
.sort((a, b) => a.usedAt - b.usedAt)[0];
|
|
if (!victim) return false;
|
|
await remove(victim.directory);
|
|
live.splice(live.indexOf(victim), 1);
|
|
}
|
|
if (input.apply && root !== company) {
|
|
for (const workspace of workspaceDirectories) {
|
|
if (workspace !== root && !live.some(entry => entry.workspace === workspace)) await fs.rmdir(workspace).catch(() => undefined);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ generations: number; bytes: number } | null> {
|
|
let generations = 0, bytes = 0;
|
|
for await (const entry of await fs.opendir(root)) {
|
|
if (++generations > limits.companyGenerations) return null;
|
|
const directory = path.join(root, entry.name);
|
|
const stat = await fs.lstat(directory);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
let files = 0;
|
|
for await (const child of await fs.opendir(directory)) {
|
|
if (++files > 3) return null;
|
|
const file = await fs.lstat(path.join(directory, child.name));
|
|
if (!file.isFile() || file.isSymbolicLink()) return null;
|
|
bytes += file.size;
|
|
if (bytes > limits.companyBytes) return null;
|
|
}
|
|
}
|
|
return { generations, bytes };
|
|
}
|
|
|
|
export async function publishWorkspaceSeedGeneration(
|
|
root: string, generation: string, seed: WorkspaceDurableSeedPaths,
|
|
options: { companyDirectory?: string; limits?: Partial<SeedCacheLimits>; eviction?: Partial<SeedCacheEviction>;
|
|
scope?: "workspace" | "company"; now?: () => number; verify?: () => Promise<boolean>;
|
|
cacheOnlyGitArchivePath?: string;
|
|
runtimeSpan?: RuntimeSpanRunner } = {},
|
|
): Promise<boolean> {
|
|
const measure = <T>(name: string, work: () => Promise<T>): Promise<T> =>
|
|
options.runtimeSpan ? options.runtimeSpan(name, work) : work();
|
|
const configuredLimits = { ...WORKSPACE_SEED_CACHE_LIMITS, ...options.limits };
|
|
const limits = options.scope === "company" ? { ...configuredLimits,
|
|
workspaceGenerations: configuredLimits.companyGenerations, workspaceBytes: configuredLimits.companyBytes,
|
|
} : configuredLimits;
|
|
const eviction = { ...WORKSPACE_SEED_CACHE_EVICTION, ...options.eviction };
|
|
if (!/^[a-f0-9]{64}$/.test(generation)
|
|
|| Object.values(configuredLimits).some(value => !Number.isSafeInteger(value) || value < 0)) return false;
|
|
root = path.resolve(root);
|
|
const company = path.resolve(options.companyDirectory ?? root);
|
|
if (root !== company && path.dirname(root) !== company) return false;
|
|
try {
|
|
await fs.mkdir(company, { recursive: true, mode: 0o700 });
|
|
const companyStat = await fs.lstat(company);
|
|
if (!companyStat.isDirectory() || companyStat.isSymbolicLink()) return false;
|
|
const cacheSeed = options.cacheOnlyGitArchivePath
|
|
? { ...seed, gitArchivePath: options.cacheOnlyGitArchivePath } : seed;
|
|
if (options.cacheOnlyGitArchivePath) {
|
|
if (!seed.gitArchivePath) return false;
|
|
const [cached, recovery] = await Promise.all([
|
|
fs.lstat(options.cacheOnlyGitArchivePath), fs.lstat(seed.gitArchivePath),
|
|
]);
|
|
// Only a separate, rebuildable archive may omit the existing flush.
|
|
// A pathname alone cannot distinguish it from a recovery hardlink.
|
|
if (!cached.isFile() || cached.isSymbolicLink() || !recovery.isFile() || recovery.isSymbolicLink()
|
|
|| (cached.dev === recovery.dev && cached.ino === recovery.ino)) return false;
|
|
}
|
|
const capacity = async (): Promise<"existing" | "available" | false> => {
|
|
const currentCompany = await fs.lstat(company);
|
|
if (!currentCompany.isDirectory() || currentCompany.isSymbolicLink()
|
|
|| currentCompany.dev !== companyStat.dev || currentCompany.ino !== companyStat.ino) return false;
|
|
let totalGenerations = 0, totalBytes = 0, workspaces = 0;
|
|
let workspace = { generations: 0, bytes: 0 };
|
|
let workspaceExists = false;
|
|
const directories = root === company ? [{ name: "" }] : await fs.opendir(company);
|
|
for await (const { name } of directories) {
|
|
if (++workspaces > limits.companyWorkspaces) return false;
|
|
const directory = root === company ? company : path.join(company, name);
|
|
const stat = await fs.lstat(directory);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
const usage = await cacheUsage(directory, limits);
|
|
if (!usage) return false;
|
|
totalGenerations += usage.generations; totalBytes += usage.bytes;
|
|
if (totalGenerations > limits.companyGenerations || totalBytes > limits.companyBytes) return false;
|
|
if (directory === root) { workspace = usage; workspaceExists = true; }
|
|
}
|
|
if (workspaceExists && await fs.lstat(path.join(root, generation)).then(() => true, () => false)) return "existing";
|
|
let incomingBytes = 4096;
|
|
for (const archive of [cacheSeed.workspaceArchivePath, cacheSeed.gitArchivePath]) {
|
|
if (!archive) continue;
|
|
const stat = await fs.lstat(archive);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
incomingBytes += stat.size;
|
|
}
|
|
if (incomingBytes > configuredLimits.workspaceBytes) return false;
|
|
if (workspace.generations + 1 > limits.workspaceGenerations || workspace.bytes + incomingBytes > limits.workspaceBytes
|
|
|| totalGenerations + 1 > limits.companyGenerations || totalBytes + incomingBytes > limits.companyBytes
|
|
|| workspaces + (workspaceExists ? 0 : 1) > limits.companyWorkspaces) return false;
|
|
assertWorkspaceManifestDiskSpace(company, incomingBytes);
|
|
return "available";
|
|
};
|
|
const initialCapacity = await measure("workspace.seed_cache_capacity", capacity);
|
|
if (initialCapacity === "existing") return true;
|
|
let incomingBytes = 4096;
|
|
for (const archive of [cacheSeed.workspaceArchivePath, cacheSeed.gitArchivePath]) if (archive) incomingBytes += (await fs.lstat(archive)).size;
|
|
if (incomingBytes > configuredLimits.workspaceBytes) return false;
|
|
const plan = { generation, incomingBytes, limits, eviction, now: options.now?.() ?? Date.now() };
|
|
if (initialCapacity === false && !await measure("workspace.seed_cache_evict", () => evictSeedGenerations(company, root, { ...plan, apply: false }))) return false;
|
|
if (options.verify && !await options.verify()) return false;
|
|
return await withDirectoryMergeLock(company, () => measure("workspace.seed_cache_locked_publish", async () => {
|
|
const lockedCompany = await fs.lstat(company);
|
|
if (!lockedCompany.isDirectory() || lockedCompany.isSymbolicLink()
|
|
|| lockedCompany.dev !== companyStat.dev || lockedCompany.ino !== companyStat.ino) return false;
|
|
await measure("workspace.seed_cache_evict", () => evictSeedGenerations(company, root, { ...plan, apply: true }));
|
|
const currentCapacity = await measure("workspace.seed_cache_capacity", capacity);
|
|
if (currentCapacity !== "available") return currentCapacity === "existing";
|
|
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
const temporary = path.join(root, `.pending-${randomUUID()}`);
|
|
await fs.mkdir(temporary, { mode: 0o700 });
|
|
try {
|
|
const copy = async (source: string, name: string, flush = true) => {
|
|
const target = path.join(temporary, name);
|
|
await measure("workspace.seed_cache_copy", () => fs.link(source, target).catch(() => fs.copyFile(source, target)));
|
|
if (!flush && seed.gitArchivePath) {
|
|
const [cached, recovery] = await Promise.all([fs.lstat(target), fs.lstat(seed.gitArchivePath)]);
|
|
if (cached.dev === recovery.dev && cached.ino === recovery.ino) throw new Error("workspace_seed_cache_recovery_alias");
|
|
}
|
|
if (flush) await measure("workspace.seed_cache_sync", async () => {
|
|
const file = await fs.open(target, "r");
|
|
try { await file.sync(); } finally { await file.close(); }
|
|
});
|
|
return measure("workspace.seed_cache_hash", () => digest(target));
|
|
};
|
|
const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar");
|
|
// Shared Git seeds have no task branch and are not recovery archives.
|
|
// Missing or corrupt cache-only bytes are rejected by the reader.
|
|
const gitSha256 = cacheSeed.gitArchivePath
|
|
? await copy(cacheSeed.gitArchivePath, "git.tar", !options.cacheOnlyGitArchivePath) : null;
|
|
await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 }));
|
|
await fs.rename(temporary, path.join(root, generation));
|
|
return true;
|
|
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
|
}));
|
|
} catch { return false; }
|
|
}
|