mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
Preserve the shared diagnostics and select ingress predicates by stable reason identity. Keep all legacy blockers conservative without verified incoming wake coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
113 lines
5.3 KiB
TypeScript
113 lines
5.3 KiB
TypeScript
import { idleApiActivity } from "./idle-api-activity.js";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
import { idleWorkSnapshot } from "./task-admission.js";
|
|
import type { IdleSleepDiagnosticSink } from "./idle-sleep-diagnostics.js";
|
|
|
|
export type IdleLocalWork = "none" | "present" | "unknown";
|
|
const spoolDirectories = new Set<string>();
|
|
const localWorkProbes = new Set<() => IdleLocalWork>();
|
|
export function registerIdleLocalWorkProbe(probe: () => IdleLocalWork): () => void {
|
|
localWorkProbes.add(probe);
|
|
return () => { localWorkProbes.delete(probe); };
|
|
}
|
|
export function registerIdleSpoolDirectory(directory: string): void { spoolDirectories.add(path.resolve(directory)); }
|
|
export const idleAccountingSpoolPath = () => path.join(resolvePaperclipInstanceRoot(), "accounting-receipts");
|
|
export const idleOrphanSpoolPath = () => process.env.SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR ??
|
|
path.join(resolvePaperclipInstanceRoot(), "data", "sandbox-orphan-cleanup");
|
|
export const idleQuotaRecoveryPath = () => path.join(resolvePaperclipInstanceRoot(), "quota-credential-recovery");
|
|
let startupComplete = false;
|
|
let ingressTracked = false;
|
|
let scheduledBackups = false;
|
|
export function markIdleIngressTracked() { ingressTracked = true; }
|
|
export function markIdleStartupComplete(options: { scheduledBackups: boolean }) {
|
|
scheduledBackups = options.scheduledBackups;
|
|
startupComplete = true;
|
|
}
|
|
|
|
/** Any entry, including malformed JSON, a temp file or a failed probe, is
|
|
* evidence to retain. Recovery readers intentionally skip some such files;
|
|
* they cannot certify that stopping the process is safe. */
|
|
export async function inspectIdleSpool(directory: string): Promise<IdleLocalWork> {
|
|
try {
|
|
const info = await fs.lstat(directory);
|
|
if (!info.isDirectory() || info.isSymbolicLink()) return "unknown";
|
|
const dir = await fs.opendir(directory);
|
|
try { return (await dir.read()) === null ? "none" : "present"; }
|
|
finally { await dir.close(); }
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "none" : "unknown";
|
|
}
|
|
}
|
|
|
|
/** Credential journals are grouped by company. Successful recovery leaves its
|
|
* empty company directory behind; every file, probe or unexpected entry still
|
|
* blocks. Never decrypt journals or follow directory symlinks during a scan. */
|
|
export async function inspectIdleQuotaRecovery(directory: string): Promise<IdleLocalWork> {
|
|
try {
|
|
const info = await fs.lstat(directory);
|
|
if (!info.isDirectory() || info.isSymbolicLink()) return "unknown";
|
|
const dir = await fs.opendir(directory);
|
|
try {
|
|
for (let entry = await dir.read(); entry !== null; entry = await dir.read()) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()
|
|
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry.name)) return "present";
|
|
const state = await inspectIdleSpool(path.join(directory, entry.name));
|
|
if (state !== "none") return state;
|
|
}
|
|
return "none";
|
|
} finally { await dir.close(); }
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "none" : "unknown";
|
|
}
|
|
}
|
|
|
|
export async function readIdleLocalWork(options: { backupCheckpoint?: boolean; diagnostic?: IdleSleepDiagnosticSink } = {}): Promise<IdleLocalWork> {
|
|
const record = options.diagnostic;
|
|
const states: IdleLocalWork[] = [];
|
|
record?.("local.startup", startupComplete ? "none" : "unknown");
|
|
record?.("local.ingress_tracking", ingressTracked ? "none" : "unknown");
|
|
if (!startupComplete || !ingressTracked) {
|
|
if (!record) return "unknown";
|
|
states.push("unknown");
|
|
}
|
|
// This exemption belongs only to a scan which will finish a new backup
|
|
// under the same admission hold before it can return an empty report.
|
|
const backup = scheduledBackups && !options.backupCheckpoint;
|
|
const active = idleWorkSnapshot().active !== 0;
|
|
record?.("local.automatic_backups", backup ? "present" : "none");
|
|
record?.("local.tracked_work", active ? "present" : "none");
|
|
if (backup || active) {
|
|
if (!record) return "present";
|
|
states.push("present");
|
|
}
|
|
let probes: IdleLocalWork;
|
|
try {
|
|
const local = [idleApiActivity.active() ? "present" : "none", ...[...localWorkProbes].map(probe => probe())];
|
|
probes = local.some(state => state !== "none" && state !== "present") ? "unknown"
|
|
: local.includes("present") ? "present" : "none";
|
|
} catch { probes = "unknown"; }
|
|
record?.("local.registered_probes", probes);
|
|
if (probes !== "none" && !record) return probes;
|
|
states.push(probes);
|
|
const accounting = idleAccountingSpoolPath();
|
|
const orphan = idleOrphanSpoolPath();
|
|
const directories = new Set([...spoolDirectories, accounting, orphan]);
|
|
const results = await Promise.all([
|
|
...[...directories].map(async directory => {
|
|
const state = await inspectIdleSpool(directory);
|
|
record?.(directory === accounting ? "local.accounting_spool"
|
|
: directory === orphan ? "local.orphan_spool" : "local.registered_spools", state);
|
|
return state;
|
|
}),
|
|
inspectIdleQuotaRecovery(idleQuotaRecoveryPath()).then(state => {
|
|
record?.("local.quota_recovery", state);
|
|
return state;
|
|
}),
|
|
]);
|
|
states.push(...results);
|
|
if (states.includes("unknown")) return "unknown";
|
|
return states.includes("present") ? "present" : "none";
|
|
}
|