mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
perf(skills): store repository packages outside Postgres
Convert legacy packages automatically, restore exact commits on cache misses, and expire unused recoverable packages while keeping warm turn checks metadata-only. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
65fa1cd617
commit
0bb36b2c6b
53 files changed
+13120
-203
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { materializeAsset } from "./runtime-context.js";
|
||||
import { removeRuntimeAssetTree, runtimeAssetRetention, RUNTIME_ASSET_RETENTION_MS } from "./runtime-asset-retention.js";
|
||||
|
||||
let home: string;
|
||||
const originalHome = process.env.PAPERCLIP_HOME;
|
||||
beforeEach(async () => { home = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-asset-gc-")); process.env.PAPERCLIP_HOME = home; });
|
||||
afterEach(async () => {
|
||||
await removeRuntimeAssetTree(home);
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
function database(rows: unknown[] = []) {
|
||||
const select = vi.fn(() => ({ from: () => ({ where: () => ({ limit: async () => rows }) }) }));
|
||||
return { db: { select } as unknown as Db, select };
|
||||
}
|
||||
async function age(digest: string) {
|
||||
const manifest = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets", "manifests", `${digest}.json`);
|
||||
const old = new Date(Date.now() - RUNTIME_ASSET_RETENTION_MS - 1_000);
|
||||
await fs.utimes(manifest, old, old);
|
||||
}
|
||||
describe("runtime asset retention", () => {
|
||||
it("has no database work when empty/fresh, removes expired copies and rebuilds the same digest", async () => {
|
||||
const { db, select } = database();
|
||||
const worker = runtimeAssetRetention(db);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
const files = [{ path: "SKILL.md", content: Buffer.from("immutable skill"), mode: 0o444 }];
|
||||
const asset = await materializeAsset(files);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(worker.nextRunAt()).toBeGreaterThan(Date.now());
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
await age(asset.digest);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(await fs.stat(asset.rootPath).catch(() => null)).toBeNull();
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(await materializeAsset(files)).toEqual(asset);
|
||||
});
|
||||
it("keeps files when an unfinished run has no trustworthy input", async () => {
|
||||
const asset = await materializeAsset([{ path: "SKILL.md", content: Buffer.from("pinned skill"), mode: 0o444 }]);
|
||||
await age(asset.digest);
|
||||
const { db } = database([{ id: "run", companyId: "company", agentId: "agent", input: null }]);
|
||||
const worker = runtimeAssetRetention(db);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect((await fs.stat(asset.rootPath)).isDirectory()).toBe(true);
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
import { ensureSkillCacheDirectory, skillRunNeedsFilesSql } from "../skill-version-cache.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
|
||||
export const RUNTIME_ASSET_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
const TOUCH_MS = 60 * 60_000;
|
||||
const touched = new Map<string, number>();
|
||||
const observers = new Set<() => void>();
|
||||
const root = () => path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
function paths(digest: string) {
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error("Invalid runtime asset digest");
|
||||
return { bundle: path.join(root(), "bundles", digest), manifest: path.join(root(), "manifests", `${digest}.json`) };
|
||||
}
|
||||
|
||||
/** Persist an hourly access lease, under the same filesystem lock as deletion.
|
||||
* Warm turns inside that lease add no I/O or database calls. The long grace also
|
||||
* protects the short interval between preparation and durable run pinning. */
|
||||
export async function withRuntimeAssetUse<T>(digest: string, consume: (locked: boolean) => Promise<T>, mutation = false): Promise<T> {
|
||||
const target = paths(digest);
|
||||
const last = touched.get(target.manifest);
|
||||
const fresh = last !== undefined && Date.now() - last < TOUCH_MS;
|
||||
if (fresh && !mutation) return consume(false);
|
||||
await ensureSkillCacheDirectory(path.dirname(target.bundle), true);
|
||||
return withDirectoryPublicationLock(target.bundle, async () => {
|
||||
const result = await consume(true);
|
||||
if (!fresh && (await fs.lstat(target.manifest).catch(() => null))?.isFile()) {
|
||||
const now = Date.now();
|
||||
await fs.utimes(target.manifest, new Date(now), new Date(now));
|
||||
if (touched.size >= 4096) touched.delete(touched.keys().next().value!);
|
||||
touched.set(target.manifest, now);
|
||||
for (const wake of observers) wake();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function protectedAssets(db: Db): Promise<Set<string> | null> {
|
||||
const rows = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
input: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'` }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.runtimeMode, "native"), skillRunNeedsFilesSql())).limit(1001);
|
||||
if (rows.length > 1000) return null;
|
||||
const pins = new Set<string>();
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const input = parseNativeExecutionInput(row.input);
|
||||
if (input.binding.runId !== row.id || input.binding.companyId !== row.companyId || input.binding.agentId !== row.agentId) return null;
|
||||
if (!("runtimeContext" in input)) return null;
|
||||
for (const asset of [input.runtimeContext.instructions.bundle, ...input.runtimeContext.skills.map(skill => skill.bundle)]) pins.add(asset.digest);
|
||||
} catch { return null; } // An unfinished/legacy input cannot prove absence.
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
export async function removeRuntimeAssetTree(directory: string) {
|
||||
const stat = await fs.lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat) return;
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe runtime asset directory");
|
||||
await fs.chmod(directory, 0o700);
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await removeRuntimeAssetTree(path.join(directory, entry.name));
|
||||
else if (!entry.isFile()) throw new Error("Unsafe runtime asset entry");
|
||||
}
|
||||
await fs.rm(directory, { recursive: true });
|
||||
}
|
||||
|
||||
export function runtimeAssetRetention(db: Db) {
|
||||
let next: number | null = null;
|
||||
async function tick(signal: AbortSignal) {
|
||||
next = null;
|
||||
const directory = path.join(root(), "manifests");
|
||||
try { await ensureSkillCacheDirectory(directory); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
const candidates: Array<{ digest: string; at: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
const manifests = await fs.readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return []; throw error;
|
||||
});
|
||||
for (const entry of manifests) {
|
||||
signal.throwIfAborted();
|
||||
if (!entry.isFile() || !/^[a-f0-9]{64}\.json$/.test(entry.name)) continue;
|
||||
const stat = await fs.lstat(path.join(directory, entry.name)).catch(() => null);
|
||||
if (!stat?.isFile()) continue;
|
||||
seen.add(entry.name.slice(0, -5));
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at <= Date.now()) candidates.push({ digest: entry.name.slice(0, -5), at });
|
||||
else next = Math.min(next ?? Infinity, at);
|
||||
}
|
||||
// A crash between bundle rename and manifest publication can leave an
|
||||
// unreferenced bundle. The same age, lock and run-pin checks apply.
|
||||
const bundles = path.join(root(), "bundles");
|
||||
try {
|
||||
await ensureSkillCacheDirectory(bundles);
|
||||
for (const entry of await fs.readdir(bundles, { withFileTypes: true })) {
|
||||
signal.throwIfAborted();
|
||||
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name) || seen.has(entry.name)) continue;
|
||||
const stat = await fs.lstat(path.join(bundles, entry.name)).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at <= Date.now()) candidates.push({ digest: entry.name, at });
|
||||
else next = Math.min(next ?? Infinity, at);
|
||||
}
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (!candidates.length) return; // No SQL merely to discover idle history.
|
||||
const pins = await protectedAssets(db);
|
||||
if (!pins) return; // Reconsider on the next asset use or startup, without polling.
|
||||
const eligible = candidates.filter(entry => !pins.has(entry.digest));
|
||||
for (const { digest } of eligible.slice(0, 32)) {
|
||||
signal.throwIfAborted();
|
||||
const target = paths(digest);
|
||||
await ensureSkillCacheDirectory(path.dirname(target.bundle), true);
|
||||
await withDirectoryPublicationLock(target.bundle, async () => {
|
||||
const stat = await fs.lstat(target.manifest).catch(() => null) ?? await fs.lstat(target.bundle).catch(() => null);
|
||||
if (!stat || (!stat.isFile() && !stat.isDirectory()) || stat.isSymbolicLink()) return;
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at > Date.now()) { next = Math.min(next ?? Infinity, at); return; }
|
||||
await removeRuntimeAssetTree(target.bundle);
|
||||
await fs.rm(target.manifest, { force: true });
|
||||
touched.delete(target.manifest);
|
||||
});
|
||||
}
|
||||
if (eligible.length > 32) next = Math.min(next ?? Infinity, Date.now() + 1_000);
|
||||
}
|
||||
function start(coordinator: Pick<ReturnType<typeof createDeliveryWorkCoordinator>, "register">) {
|
||||
let requested = 0, completed = -1;
|
||||
const worker = coordinator.register(DELIVERY_QUEUES.runtimeAssetRetention, {
|
||||
retryMs: 5_000, pausePassiveMaintenance: true,
|
||||
async run(signal) { const generation = requested; await tick(signal); completed = generation; },
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => requested !== completed ? Date.now() + 5_000 : next,
|
||||
});
|
||||
const wake = () => { requested++; worker.wake(); };
|
||||
observers.add(wake);
|
||||
return { ...worker, async stop() { observers.delete(wake); await worker.stop(); } };
|
||||
}
|
||||
return { tick, start, nextRunAt: () => next };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, chmod, lstat, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -290,7 +291,11 @@ describe("buildNativeRuntimeContext", () => {
|
||||
};
|
||||
|
||||
const context = await buildNativeRuntimeContext(input);
|
||||
const reads = vi.spyOn(fs, "readFile");
|
||||
const repeated = await buildNativeRuntimeContext(input);
|
||||
expect(reads.mock.calls.some(([file]) => String(file).startsWith(selectedRoot)
|
||||
|| String(file).startsWith(context.skills[0]!.bundle.rootPath))).toBe(false);
|
||||
reads.mockRestore();
|
||||
|
||||
expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true });
|
||||
expect(serviceMocks.getEffectiveToolAssignmentsForAgent).toHaveBeenCalledWith("company-1", "agent-1");
|
||||
@@ -322,6 +327,19 @@ describe("buildNativeRuntimeContext", () => {
|
||||
expect(repeated.aggregateDigest).toBe(context.aggregateDigest);
|
||||
expect(repeated.instructions.bundle.rootPath).toBe(context.instructions.bundle.rootPath);
|
||||
expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath);
|
||||
await writeFile(path.join(selectedRoot, "references/checklist.md"), "- Verify edits\n");
|
||||
const edited = await buildNativeRuntimeContext(input);
|
||||
expect(edited.skills[0]!.bundle.digest).not.toBe(context.skills[0]!.bundle.digest);
|
||||
const corrupted = path.join(edited.skills[0]!.bundle.rootPath, "SKILL.md");
|
||||
await chmod(corrupted, 0o644);
|
||||
await writeFile(corrupted, "Corrupted saved asset");
|
||||
const repaired = await buildNativeRuntimeContext(input);
|
||||
expect(repaired.skills[0]!.bundle.digest).toBe(edited.skills[0]!.bundle.digest);
|
||||
expect(await readFile(corrupted, "utf8")).toContain("Use the checklist");
|
||||
await chmod(path.dirname(corrupted), 0o755);
|
||||
await fs.rm(corrupted);
|
||||
expect((await buildNativeRuntimeContext(input)).skills[0]!.bundle.digest).toBe(edited.skills[0]!.bundle.digest);
|
||||
expect(await readFile(corrupted, "utf8")).toContain("Use the checklist");
|
||||
});
|
||||
|
||||
it("does not materialize assigned skills or external MCP in preconfigured mode", async () => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { withRuntimeAssetUse, removeRuntimeAssetTree } from "./runtime-asset-retention.js";
|
||||
import { cachedSkillAsset, rememberSkillAsset } from "./skill-asset-cache.js";
|
||||
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
|
||||
import { measureRunPhase } from "../execution-preparation-timing.js";
|
||||
import { spekoToolsForSession } from "../voice/speko-agent-tools.js";
|
||||
import { githubBotConnectionIdsForRun } from "../chat-github-tools.js";
|
||||
@@ -83,14 +86,9 @@ async function verifyMaterializedAsset(
|
||||
rootPath: string,
|
||||
manifestFiles: Array<{ path: string; sha256: string; mode: number; size: number }>,
|
||||
): Promise<void> {
|
||||
const actual = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(rootPath));
|
||||
const actualByPath = new Map(actual.map((file) => [file.path, file]));
|
||||
if (actualByPath.size !== manifestFiles.length) throw new Error("runtime context asset file count mismatch");
|
||||
for (const expected of manifestFiles) {
|
||||
const file = actualByPath.get(expected.path);
|
||||
if (!file || file.content.byteLength !== expected.size || sha256(file.content) !== expected.sha256 || (file.mode & 0o555) !== expected.mode) {
|
||||
throw new Error(`runtime context asset digest mismatch: ${expected.path}`);
|
||||
}
|
||||
const expected = manifestFiles.map(file => ({ ...file, executable: Boolean(file.mode & 0o111) }));
|
||||
if (!await measureRunPhase("runtime_context.asset_read", () => verifyFileManifest(rootPath, expected))) {
|
||||
throw new Error("runtime context asset digest mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,55 +103,62 @@ export async function materializeAsset(files: AssetFile[]): Promise<NativeRuntim
|
||||
const manifestDigest = sha256(manifestText);
|
||||
return { sorted, manifestFiles, totalBytes, assetDigest, manifestText, manifestDigest };
|
||||
});
|
||||
const assetsRoot = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
const rootPath = path.join(assetsRoot, "bundles", assetDigest);
|
||||
const manifestPath = path.join(assetsRoot, "manifests", `${assetDigest}.json`);
|
||||
const existing = await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8").catch(() => null));
|
||||
if (existing !== null) {
|
||||
if (sha256(existing) !== manifestDigest || !(await fs.stat(rootPath).catch(() => null))?.isDirectory()) throw new Error(`runtime context asset verification failed: ${assetDigest}`);
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
}
|
||||
await measureRunPhase("runtime_context.asset_materialize", async () => {
|
||||
const stagingRoot = path.join(assetsRoot, ".staging", randomUUID());
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const file of sorted) {
|
||||
const target = path.join(stagingRoot, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, file.content, { mode: file.mode & 0o555 });
|
||||
await fs.chmod(target, file.mode & 0o555);
|
||||
}
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true, mode: 0o700 });
|
||||
let publishedRoot = false;
|
||||
await fs.rename(stagingRoot, rootPath).then(() => {
|
||||
publishedRoot = true;
|
||||
}).catch(async (error: NodeJS.ErrnoException) => {
|
||||
// Concurrent publishers can surface EACCES on macOS when the winning
|
||||
// destination has already been made read-only. Treat that as a race only
|
||||
// when the content-addressed destination now exists; a genuine permission
|
||||
// failure with no published destination must still fail loudly.
|
||||
const publishedByPeer = (await fs.stat(rootPath).catch(() => null))?.isDirectory() === true;
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && !publishedByPeer) throw error;
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
});
|
||||
// Publish the writable staging directory first. Renaming a read-only
|
||||
// directory can fail with EACCES on macOS even when both parents are owned
|
||||
// and writable. Files are already immutable; directories become immutable
|
||||
// immediately after this process wins publication.
|
||||
if (publishedRoot) await makeDirectoriesReadOnly(rootPath);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(manifestPath, manifestText, { flag: "wx", mode: 0o400 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
if (sha256(await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8"))) !== manifestDigest) throw new Error(`runtime context manifest race mismatch: ${assetDigest}`);
|
||||
});
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
return withRuntimeAssetUse(assetDigest, async function publishOrReuse(locked): Promise<NativeRuntimeAssetReference> {
|
||||
const assetsRoot = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
const rootPath = path.join(assetsRoot, "bundles", assetDigest);
|
||||
const manifestPath = path.join(assetsRoot, "manifests", `${assetDigest}.json`);
|
||||
const existing = await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8").catch(() => null));
|
||||
const rootExists = (await fs.stat(rootPath).catch(() => null))?.isDirectory();
|
||||
if (existing !== null && rootExists && sha256(existing) === manifestDigest
|
||||
&& await verifyFileManifest(rootPath, manifestFiles.map(file => ({ ...file, executable: Boolean(file.mode & 0o111) })))) {
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
}
|
||||
}, { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
if (!locked) return withRuntimeAssetUse(assetDigest, publishOrReuse, true);
|
||||
// The caller supplied the exact authoritative bytes for this digest. A missing
|
||||
// or damaged disposable copy can be rebuilt under the publication/GC lock.
|
||||
if (rootExists) await removeRuntimeAssetTree(rootPath);
|
||||
await fs.rm(manifestPath, { force: true });
|
||||
await measureRunPhase("runtime_context.asset_materialize", async () => {
|
||||
const stagingRoot = path.join(assetsRoot, ".staging", randomUUID());
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const file of sorted) {
|
||||
const target = path.join(stagingRoot, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, file.content, { mode: file.mode & 0o555 });
|
||||
await fs.chmod(target, file.mode & 0o555);
|
||||
}
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true, mode: 0o700 });
|
||||
let publishedRoot = false;
|
||||
await fs.rename(stagingRoot, rootPath).then(() => {
|
||||
publishedRoot = true;
|
||||
}).catch(async (error: NodeJS.ErrnoException) => {
|
||||
// Concurrent publishers can surface EACCES on macOS when the winning
|
||||
// destination has already been made read-only. Treat that as a race only
|
||||
// when the content-addressed destination now exists; a genuine permission
|
||||
// failure with no published destination must still fail loudly.
|
||||
const publishedByPeer = (await fs.stat(rootPath).catch(() => null))?.isDirectory() === true;
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && !publishedByPeer) throw error;
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
});
|
||||
// Publish the writable staging directory first. Renaming a read-only
|
||||
// directory can fail with EACCES on macOS even when both parents are owned
|
||||
// and writable. Files are already immutable; directories become immutable
|
||||
// immediately after this process wins publication.
|
||||
if (publishedRoot) await makeDirectoriesReadOnly(rootPath);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(manifestPath, manifestText, { flag: "wx", mode: 0o400 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
if (sha256(await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8"))) !== manifestDigest) throw new Error(`runtime context manifest race mismatch: ${assetDigest}`);
|
||||
});
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}, { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFiles = false) {
|
||||
@@ -180,7 +185,7 @@ async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFi
|
||||
return { entryPath, bundle: await materializeAsset(files) };
|
||||
}
|
||||
|
||||
async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
|
||||
export async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
|
||||
const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries).filter(
|
||||
(key) => !omitLegacy || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
);
|
||||
@@ -189,7 +194,17 @@ async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>,
|
||||
const entry = byKey.get(key);
|
||||
if (!entry) throw new Error(`assigned runtime skill is missing from the company library: ${key}`);
|
||||
if (entry.sourceStatus === "missing") throw new Error(entry.missingDetail ?? `assigned runtime skill source is missing: ${key}`);
|
||||
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle: await materializeAsset(await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source))) };
|
||||
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(entry.source));
|
||||
if (!bundle) {
|
||||
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source));
|
||||
bundle = await materializeAsset(files);
|
||||
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
|
||||
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
|
||||
// Populate the stat/digest cache against these exact source bytes before
|
||||
// remembering the reference. A concurrent source edit cannot seed it.
|
||||
if (await verifyFileManifest(entry.source, manifest)) rememberSkillAsset(entry.source, manifest, bundle);
|
||||
}
|
||||
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle };
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { withRuntimeAssetUse } from "./runtime-asset-retention.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { NativeRuntimeAssetReference } from "../../vendor/paperclip-runner/index.js";
|
||||
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
|
||||
type Cached = { manifest: FileManifestEntry[]; bundle: NativeRuntimeAssetReference };
|
||||
const entries = new Map<string, Cached>();
|
||||
let fileCount = 0;
|
||||
const MAX_FILES = 32_000;
|
||||
const MAX_ENTRIES = 128;
|
||||
const cacheKey = (source: string) => `${resolvePaperclipInstanceRoot()}:${source}`;
|
||||
|
||||
/** The caller supplies freshly authorized runtime entries on every turn. This
|
||||
* cache retains no authority or file bodies, and verifies source and destination
|
||||
* identities before reusing the content-addressed native asset reference. */
|
||||
export async function cachedSkillAsset(source: string): Promise<NativeRuntimeAssetReference | null> {
|
||||
const cached = entries.get(cacheKey(source));
|
||||
if (!cached || !await verifyFileManifest(source, cached.manifest)) return null;
|
||||
return withRuntimeAssetUse(cached.bundle.digest, async () => {
|
||||
const manifestPath = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets", "manifests", `${cached.bundle.digest}.json`);
|
||||
const manifest = await fs.readFile(manifestPath).catch(() => null);
|
||||
if (!manifest || !(await fs.lstat(cached.bundle.rootPath).catch(() => null))?.isDirectory()) return null;
|
||||
if (createHash("sha256").update(manifest).digest("hex") !== cached.bundle.manifestDigest
|
||||
|| !await verifyFileManifest(cached.bundle.rootPath, cached.manifest)) {
|
||||
return null;
|
||||
}
|
||||
return cached.bundle;
|
||||
});
|
||||
}
|
||||
|
||||
export function rememberSkillAsset(source: string, manifest: FileManifestEntry[], bundle: NativeRuntimeAssetReference) {
|
||||
if (manifest.length > MAX_FILES) return;
|
||||
const key = cacheKey(source);
|
||||
const previous = entries.get(key);
|
||||
if (previous) { fileCount -= previous.manifest.length; entries.delete(key); }
|
||||
while (entries.size && (fileCount + manifest.length > MAX_FILES || entries.size >= MAX_ENTRIES)) {
|
||||
const first = entries.keys().next().value!;
|
||||
fileCount -= entries.get(first)!.manifest.length;
|
||||
entries.delete(first);
|
||||
}
|
||||
entries.set(key, { manifest, bundle });
|
||||
fileCount += manifest.length;
|
||||
}
|
||||
Reference in new issue
Block a user