mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip manages agents and their runtime configuration. > - Plugins can maintain external agent computers. > - Configuration and file saves currently miss durable plugin delivery. > - A transient activity notification cannot recover after interruption. > - This pull request adds an opt-in agent runtime resource stream. > - The existing dispatcher delivers and acknowledges its committed work. > - Plugins can prepare computers when configuration changes. ## Linked Issues or Issue Description Related: #15631 and #16203 establish agent lifecycle and native durable delivery. **Problem** Plugins cannot reliably react to agent configuration and instruction changes without polling or waiting for execution. **Proposed solution** Add an opt-in `agent_runtime` stream to the existing resource journal. Record changes at the database mutation and managed file audit boundaries. Wake the dispatcher with content-free commit notifications. Provide scoped runtime inputs to the selected lifecycle plugin. **Alternatives considered** Route-only activity events miss service and background writers. Scheduled scans delay changes and repeatedly query unchanged state. **Roadmap alignment** This extends the existing plugin and agent configuration work. ## What Changed - Add durable update/delete events for agent runtime state, including an existing-agent baseline. - Capture configuration changes, instruction edits, deletion, restore, and managed run writeback. - Add commit notifications and a reconnect scan. Register one listener for configured, enabled runtime subscribers. Release it when none remain. - Keep HTTP startup independent of queued plugin callbacks. - Add `ctx.agents.getRuntime`. Require lifecycle capability, invocation company, enabled plugin, and owning lifecycle selection. - Keep legacy agent/project subscriptions isolated. Manual inbox reads retain both legacy streams. - Resolve recovered instruction paths without reading or hashing bundle contents. Use the company reporting graph for runtime health. - Repair missed file events on identical save/deletion retries after a journal failure; preserve content tokens and conditional-write checks. - Correlate file audits with runtime revisions to deduplicate recorded retries without a new table. Bound the lookup with the existing company/timestamp index. - Document the contract, migration, access boundaries, and session-connection requirement. ## Verification - Rebased the full stack onto origin/master after #16507. - Final replayed stack: `pnpm -r typecheck` and `pnpm build` passed. - All 132 affected runtime, skill, connector, publication, instruction, managed-agent, and delivery tests passed after the latest-master rebase. Workspace typecheck and build passed. - Journal failure injection verifies recovery on identical file writes, deletions, and instruction retries from an acknowledged inbox. - Deduplication follow-up: 80 runtime/instruction/delivery tests and server typecheck passed. Repeated retries create no audit/events; old hashes returning after an intervening commit still repair delivery. Built-in/native instruction writer regressions passed all 56 tests. - PR 1 follow-up tests cover valid/cyclic reporting chains, listener registration failure, multiple subscribers, global/company disable, re-enable, unload, and startup with blocked callbacks. - Database coverage includes rollback, journal failure, no-op suppression, file saves/deletion, ordering/replay, owner/company denial, independent-client commits, and real listener loss/reconnect. - SDK/shared suites passed 1,059 tests during implementation. CLI/delivery regressions also passed. - Migration safety, map checks, diff whitespace, and local secrets/PII scan passed. - Hosted CI and security checks are fully green at `b296405e2`. Standard Greptile and Apex both returned 5/5 with no unresolved findings. - The broad local suite encountered an unchanged diagnostic test that rejects the word "private" from an error stack. This worktree lives under /private/tmp. The same unchanged diagnostic suite passed all 108 tests in the original checkout. Hosted full-suite checks provide exact-head verification. ## Risks - Apply migration 0347 before enabling a runtime subscriber. Runtime notifications require session-level PostgreSQL LISTEN. Transaction-mode poolers are unsupported for this opt-in feature. - Database triggers share the mutation transaction. Journal failure prevents configuration commits. Trigger work adds a journal insert to relevant changes. - Filesystem publication and SQL commit are separate boundaries. Direct filesystem edits do not emit hooks. External clients must fence stale revisions and retain conditional-write checks. - The reader returns only that plugin's own instance secrets. Keep them out of UI data, logs, plugin state, and agent files. - No private transport integration or live remote computer qualification is included. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and testing. The exact deployment model ID and context window are not exposed in this session. ## 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 references) - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run tests locally and they pass (focused suites; broad local limitation documented above) - [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>
308 lines
19 KiB
TypeScript
308 lines
19 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import { cachedAgentFileManifest, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js";
|
|
import { constants } from "node:fs";
|
|
import { Readable } from "node:stream";
|
|
import path from "node:path";
|
|
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
|
import { agents, activityLog, agentInstructionHeads, agentInstructionRevisions, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
|
import { captureDirectorySnapshot, mergeDirectoryWithBaseline, type DirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
|
import { HttpError, conflict, notFound, unprocessable } from "../errors.js";
|
|
import { authorizeInstructionCommit, authorizeInstructionRead } from "./agent-instruction-authorization.js";
|
|
import { assertInstructionPathSafe, instructionPath, instructionBytes, materializeInstructionBytes, readInstructionBytes, MAX_INSTRUCTION_BYTES } from "./agent-instruction-files.js";
|
|
import { agentInstructionsBundleMode, deriveBundleState, resolveManagedInstructionsRoot } from "./agent-instructions.js";
|
|
import type { AuthorizationActor } from "./authorization.js";
|
|
|
|
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type Agent = typeof agents.$inferSelect;
|
|
export const AGENT_FILES_CONTRACT = "paperclip.agent-files.v1";
|
|
export const MAX_AGENT_FILE_BYTES = 256 * 1024 * 1024;
|
|
export const MAX_AGENT_DIRECTORY_BYTES = 2 * 1024 * 1024 * 1024;
|
|
export const MAX_AGENT_DIRECTORY_ENTRIES = 100_000;
|
|
export const fileHash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
|
|
export class AgentFileLimitError extends HttpError {
|
|
constructor(message: string) {
|
|
super(422, message, { code: "AGENT_FILES_LIMIT_EXCEEDED" });
|
|
}
|
|
}
|
|
function assertFileSize(size: number, relative: string) {
|
|
if (size > MAX_AGENT_FILE_BYTES) throw new AgentFileLimitError(`Agent file "${relative}" exceeds the 256 MiB per-file limit`);
|
|
}
|
|
function assertDirectorySize(size: number, count: number) {
|
|
if (size > MAX_AGENT_DIRECTORY_BYTES) throw new AgentFileLimitError("Agent directory exceeds the 2 GiB total storage limit");
|
|
if (count > MAX_AGENT_DIRECTORY_ENTRIES) throw new AgentFileLimitError("Agent directory exceeds the 100,000-entry limit (files and folders)");
|
|
}
|
|
export function agentStorageWarning(detail: string) {
|
|
return `Agent storage is full. ${detail}. Runs can continue; remove or shrink files in AGENT_HOME to free space. Changes exceeding the storage limits will not be saved.`;
|
|
}
|
|
function storageWarning(size: number, count: number, fullFile?: string) {
|
|
if (size >= MAX_AGENT_DIRECTORY_BYTES) return agentStorageWarning("The agent folder has reached its 2 GiB limit");
|
|
if (count >= MAX_AGENT_DIRECTORY_ENTRIES) return agentStorageWarning("The agent folder has reached its 100,000-entry limit");
|
|
if (fullFile) return agentStorageWarning(`Agent file "${fullFile}" has reached its 256 MiB limit`);
|
|
return null;
|
|
}
|
|
|
|
export function agentFilePath(value: string): string {
|
|
const relative = instructionPath(value);
|
|
if (relative.split("/").includes(".paperclip-runtime") || relative === "promptTemplate.legacy.md") {
|
|
throw unprocessable(`${relative} is reserved and cannot be used for agent files`);
|
|
}
|
|
return relative;
|
|
}
|
|
|
|
/** Compatibility ETag for clients whose old schema requires a UUID. This is a
|
|
* content token, not a revision ID: no snapshot or history row is created. */
|
|
export function agentFileToken(bytes: Uint8Array): string {
|
|
return agentFileTokenFromHash(fileHash(bytes));
|
|
}
|
|
export function agentFileTokenFromHash(h: string): string {
|
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-8${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
}
|
|
|
|
/** Call under the agent row lock. Import deployed revision heads once, then the
|
|
* directory is authoritative. The adoption marker lives OUTSIDE agent files. */
|
|
export async function adoptAgentFiles(tx: Tx, agent: Agent): Promise<string> {
|
|
if (agentInstructionsBundleMode(agent) === "external") throw unprocessable("External instructions must be migrated to managed storage first");
|
|
const root = resolveManagedInstructionsRoot(agent);
|
|
const markerRoot = path.join(path.dirname(root), "file-sync");
|
|
const marker = "adopted.json";
|
|
await assertInstructionPathSafe(root, deriveBundleState(agent).entryFile);
|
|
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
const filename = await assertInstructionPathSafe(markerRoot, marker);
|
|
const adopted = await fs.readFile(filename, "utf8").catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
if (adopted !== null) {
|
|
if (JSON.parse(adopted).schema !== AGENT_FILES_CONTRACT) throw new Error("Unsupported agent file contract");
|
|
return root;
|
|
}
|
|
const legacy = await tx.select({ entryFile: agentInstructionHeads.entryFile, contentBase64: agentInstructionRevisions.contentBase64 })
|
|
.from(agentInstructionHeads).innerJoin(agentInstructionRevisions, eq(agentInstructionHeads.revisionId, agentInstructionRevisions.id))
|
|
.where(and(eq(agentInstructionHeads.companyId, agent.companyId), eq(agentInstructionHeads.agentId, agent.id)));
|
|
for (const entry of legacy) await materializeInstructionBytes(root, entry.entryFile, Buffer.from(entry.contentBase64, "base64"));
|
|
await materializeInstructionBytes(markerRoot, marker, Buffer.from(JSON.stringify({ schema: AGENT_FILES_CONTRACT })));
|
|
return root;
|
|
}
|
|
|
|
async function openAgentFile(root: string, relative: string) {
|
|
const filename = await assertInstructionPathSafe(root, relative);
|
|
const handle = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
if (!handle) return null;
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile() || stat.nlink !== 1) throw unprocessable("Agent files must be regular files without links");
|
|
assertFileSize(stat.size, relative);
|
|
return { handle, size: stat.size };
|
|
} catch (error) { await handle.close(); throw error; }
|
|
}
|
|
|
|
/** Hash large files incrementally. Only editor-sized content is retained in RAM. */
|
|
export async function inspectAgentFile(root: string, relative: string, bufferLimit = MAX_INSTRUCTION_BYTES) {
|
|
const opened = await openAgentFile(root, relative);
|
|
if (!opened) return null;
|
|
const hash = createHash("sha256");
|
|
let chunks: Buffer[] | null = opened.size <= bufferLimit ? [] : null;
|
|
let size = 0;
|
|
try {
|
|
for await (const chunk of opened.handle.createReadStream()) {
|
|
size += chunk.length;
|
|
assertFileSize(size, relative);
|
|
hash.update(chunk);
|
|
if (size > bufferLimit) chunks = null;
|
|
chunks?.push(chunk);
|
|
}
|
|
return { size, hash: hash.digest("hex"), bytes: chunks ? Buffer.concat(chunks) : null };
|
|
} finally { await opened.handle.close(); }
|
|
}
|
|
|
|
export async function readAgentFile(root: string, relative: string): Promise<Buffer | null> {
|
|
return (await inspectAgentFile(root, relative, MAX_AGENT_FILE_BYTES))?.bytes ?? null;
|
|
}
|
|
|
|
/** Validate before staging and again after provider stop; never follow links or
|
|
* silently skip an unsupported file. Bounds apply to bytes, including binaries. */
|
|
async function scanAgentFiles(root: string, enforceLimits = true) {
|
|
await assertInstructionPathSafe(root, ".path-check");
|
|
let size = 0, count = 0;
|
|
const entries = new Set<string>();
|
|
let fullFile: string | undefined;
|
|
async function walk(dir: string) {
|
|
for (const item of await fs.readdir(path.join(root, dir), { withFileTypes: true })) {
|
|
const relative = agentFilePath(dir ? `${dir}/${item.name}` : item.name);
|
|
const stat = await fs.lstat(path.join(root, relative));
|
|
count++;
|
|
if (enforceLimits) assertDirectorySize(size, count);
|
|
entries.add(relative);
|
|
if (stat.isDirectory()) await walk(relative);
|
|
else if (stat.isFile() && stat.nlink === 1) {
|
|
size += stat.size;
|
|
if (stat.size >= MAX_AGENT_FILE_BYTES) fullFile ??= relative;
|
|
if (enforceLimits) {
|
|
assertFileSize(stat.size, relative);
|
|
assertDirectorySize(size, count);
|
|
}
|
|
} else throw unprocessable("Agent directories support regular files and directories, without links or special files");
|
|
}
|
|
}
|
|
await walk("");
|
|
return { size, entries, storageWarning: storageWarning(size, count, fullFile) };
|
|
}
|
|
|
|
export async function inspectAgentDirectory(root: string, enforceLimits = true) {
|
|
const usage = await scanAgentFiles(root, enforceLimits);
|
|
return { snapshot: await captureDirectorySnapshot(root), storageWarning: usage.storageWarning };
|
|
}
|
|
export async function snapshotAgentFiles(root: string, enforceLimits = true): Promise<DirectorySnapshot> {
|
|
return (await inspectAgentDirectory(root, enforceLimits)).snapshot;
|
|
}
|
|
|
|
export async function isAgentFileSaveRecorded(tx: Tx, agent: Agent, relative: string, contentHash: string | null): Promise<boolean> {
|
|
if (agent.status === "pending_approval" || agent.status === "terminated") return true;
|
|
const [latest] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents)
|
|
.where(and(eq(resourceLifecycleEvents.companyId, agent.companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agent.id)))
|
|
.orderBy(desc(resourceLifecycleEvents.id)).limit(1);
|
|
if (!latest) return false;
|
|
const hash = sql<string>`${activityLog.details}->>'contentHash'`;
|
|
const [recorded] = await tx.select({ id: activityLog.id }).from(activityLog)
|
|
.innerJoin(resourceLifecycleEvents, and(eq(resourceLifecycleEvents.id, latest.id), eq(activityLog.createdAt, resourceLifecycleEvents.createdAt))).where(and(
|
|
eq(activityLog.companyId, agent.companyId), eq(activityLog.entityType, "agent"), eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.files_updated"),
|
|
eq(sql<string>`${activityLog.details}->>'agentRuntimeRevision'`, String(latest.id)),
|
|
eq(sql<string>`${activityLog.details}->>'path'`, relative), contentHash === null ? isNull(hash) : eq(hash, contentHash),
|
|
)).limit(1);
|
|
return Boolean(recorded);
|
|
}
|
|
|
|
export function agentFileStore(db: Db) {
|
|
async function locked<T>(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean,
|
|
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise<T>) {
|
|
return db.transaction(async tx => {
|
|
const [agent] = await tx.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
|
|
if (!agent) throw notFound("Agent not found");
|
|
const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent);
|
|
const root = await adoptAgentFiles(tx, agent);
|
|
return fn(tx, agent, root, bound);
|
|
});
|
|
}
|
|
async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record<string, unknown>) {
|
|
await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: actor.type === "board" ? "user" : "agent",
|
|
actorId: (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: actor.type === "agent" ? actor.agentId : null,
|
|
runId: actor.runId, responsibleUserId: actor.type === "board" ? actor.userId : actor.onBehalfOfUserId,
|
|
action: "agent.files_updated", entityType: "agent", entityId: agent.id, details });
|
|
}
|
|
return {
|
|
locked,
|
|
download: async (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) => {
|
|
const opened: { file: Awaited<ReturnType<typeof openAgentFile>> } = { file: null };
|
|
try {
|
|
const file = await locked(companyId, agentId, actor, false, async (_tx, _agent, root) => {
|
|
opened.file = await openAgentFile(root, instructionPath(relative));
|
|
return opened.file;
|
|
});
|
|
// Transfer outside the database lock. The open descriptor pins the file
|
|
// across concurrent atomic replacements; end bounds concurrent growth.
|
|
if (!file) return null;
|
|
if (file.size === 0) { await file.handle.close(); return { size: 0, stream: Readable.from([]) }; }
|
|
return { size: file.size, stream: file.handle.createReadStream({ end: file.size - 1 }) };
|
|
} catch (error) { await opened.file?.handle.close(); throw error; }
|
|
},
|
|
read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) =>
|
|
locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))),
|
|
write: (input: { companyId: string; agentId: string; path: string; bytes: Buffer | null; baseHash: string | null }, actor: AuthorizationActor) =>
|
|
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
|
|
const relative = agentFilePath(input.path);
|
|
if (input.bytes) assertFileSize(input.bytes.length, relative);
|
|
const previous = await inspectAgentFile(root, relative);
|
|
const currentHash = previous?.hash ?? null;
|
|
const incomingHash = input.bytes === null ? null : fileHash(input.bytes);
|
|
if (currentHash === incomingHash) {
|
|
if (!await isAgentFileSaveRecorded(tx, agent, relative, incomingHash)) await audit(tx, agent, bound, { path: relative, contentHash: incomingHash });
|
|
return { contentHash: currentHash, changed: false };
|
|
}
|
|
if (currentHash !== input.baseHash) throw conflict("This file changed since it was read. Reload before saving.", { code: "AGENT_FILE_CONFLICT", path: relative, currentHash });
|
|
if (input.bytes === null && relative === deriveBundleState(agent).entryFile) throw unprocessable("The configured instruction entry cannot be deleted");
|
|
if (input.bytes !== null) {
|
|
if (relative === deriveBundleState(agent).entryFile) instructionBytes(input.bytes);
|
|
// A quota check needs metadata only. Do not hash unrelated large
|
|
// files while holding the editor's row lock for a one-file save.
|
|
const snapshot = await scanAgentFiles(root);
|
|
const total = snapshot.size + input.bytes.length - (previous?.size ?? 0);
|
|
const newEntries = relative.split("/").map((_part, i, parts) => parts.slice(0, i + 1).join("/")).filter(name => !snapshot.entries.has(name)).length;
|
|
assertDirectorySize(total, snapshot.entries.size + newEntries);
|
|
}
|
|
if (input.bytes === null) await fs.unlink(await assertInstructionPathSafe(root, relative));
|
|
else await materializeInstructionBytes(root, relative, input.bytes);
|
|
await audit(tx, agent, bound, { path: relative, contentHash: incomingHash });
|
|
return { contentHash: incomingHash, changed: true };
|
|
}),
|
|
apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) =>
|
|
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
|
|
const incoming = input.checkpoint ? checkpointSnapshot(input.checkpoint) : await snapshotAgentFiles(input.sourceDir);
|
|
const entryPath = deriveBundleState(agent).entryFile;
|
|
if (incoming.entries.get(entryPath)?.kind !== "file") throw unprocessable("The configured instruction entry cannot be deleted");
|
|
if (!input.checkpoint || JSON.stringify(incoming.entries.get(entryPath)) !== JSON.stringify(input.baseline.entries.get(entryPath))) {
|
|
const entry = await readInstructionBytes(input.sourceDir, entryPath);
|
|
if (entry === null) throw unprocessable("The configured instruction entry cannot be deleted");
|
|
instructionBytes(entry);
|
|
}
|
|
// Previously saved/imported bytes must remain readable, including when
|
|
// over quota. Enforce limits on the incoming and resulting tree so a
|
|
// run can delete files to recover instead of being locked out forever.
|
|
const current = input.checkpoint ? checkpointSnapshot(await cachedAgentFileManifest(root)) : await snapshotAgentFiles(root, false);
|
|
// Rebase only the run's changed paths onto the current tree. This makes
|
|
// same-file edits/deletions last-sync-wins while untouched files retain
|
|
// changes from other runs. Ancestors may need recreating after a writer
|
|
// replaced a directory with a file.
|
|
const entries = new Map(input.baseline.entries);
|
|
const changed = [...new Set([...input.baseline.entries, ...incoming.entries].map(([name]) => name))]
|
|
.filter(name => JSON.stringify(input.baseline.entries.get(name)) !== JSON.stringify(incoming.entries.get(name)));
|
|
for (const name of changed) {
|
|
const present = current.entries.get(name);
|
|
if (present) entries.set(name, present); else entries.delete(name);
|
|
if (incoming.entries.has(name)) {
|
|
for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) {
|
|
if (current.entries.get(parent)?.kind === "dir") continue;
|
|
const before = current.entries.get(parent);
|
|
if (before) entries.set(parent, before); else entries.delete(parent);
|
|
}
|
|
}
|
|
}
|
|
const applyBaseline = { ...input.baseline, entries };
|
|
const finalEntries = new Map([...current.entries].map(([name, value]) => [name, { value, root }]));
|
|
for (const name of changed) if (!incoming.entries.has(name)) finalEntries.delete(name);
|
|
for (const [name, value] of incoming.entries) {
|
|
if (JSON.stringify(input.baseline.entries.get(name)) === JSON.stringify(value)) continue;
|
|
if (value.kind !== "dir") for (const child of finalEntries.keys()) if (child.startsWith(`${name}/`)) finalEntries.delete(child);
|
|
finalEntries.set(name, { value, root: input.sourceDir });
|
|
for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) {
|
|
if (finalEntries.get(parent)?.value.kind !== "dir") finalEntries.set(parent, { value: { kind: "dir" }, root: input.sourceDir });
|
|
}
|
|
}
|
|
// An unchanged file created by another run can keep a removed folder
|
|
// alive. Count those surviving parent directories in quota preflight.
|
|
for (const name of finalEntries.keys()) {
|
|
for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) {
|
|
if (!finalEntries.has(parent)) finalEntries.set(parent, { value: { kind: "dir" }, root });
|
|
}
|
|
}
|
|
let total = 0;
|
|
let fullFile: string | undefined;
|
|
for (const [name, item] of finalEntries) if (item.value.kind === "file") {
|
|
const size = (await fs.stat(path.join(item.root, name))).size;
|
|
assertFileSize(size, name);
|
|
if (size >= MAX_AGENT_FILE_BYTES) fullFile ??= name;
|
|
total += size;
|
|
}
|
|
assertDirectorySize(total, finalEntries.size);
|
|
await mergeDirectoryWithBaseline({ ...input, baseline: applyBaseline, targetDir: root,
|
|
...(input.checkpoint ? { snapshots: { source: incoming, current } } : {}) });
|
|
await audit(tx, agent, bound, { sourceRunId: actor.runId, contract: AGENT_FILES_CONTRACT });
|
|
return { storageWarning: storageWarning(total, finalEntries.size, fullFile) };
|
|
}),
|
|
};
|
|
}
|