mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Reviewed and verified at 9f87b490d1.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
126 lines
7.7 KiB
JavaScript
126 lines
7.7 KiB
JavaScript
import { lstat, mkdtemp, mkdir, readFile, readlink, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
// Explicit opt-in test setup. No manifests, lockfiles or other worktrees change.
|
|
const server = fileURLToPath(new URL('../../server/', import.meta.url));
|
|
export const otelPeerNames = ['@opentelemetry/sdk-node', '@opentelemetry/auto-instrumentations-node', '@opentelemetry/exporter-trace-otlp-http', '@opentelemetry/resources', '@opentelemetry/semantic-conventions'];
|
|
const ledgerPath = root => path.join(root, 'node_modules', '.paperclip-sleep-otel-links.json');
|
|
async function entryAt(destination) {
|
|
try { return await lstat(destination); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
}
|
|
async function versionAt(root, name) {
|
|
try { return JSON.parse(await readFile(path.join(root, 'node_modules', name, 'package.json'), 'utf8')).version; }
|
|
catch (error) { if (error.code !== 'ENOENT') throw error; return undefined; }
|
|
}
|
|
async function readOwnership(root) {
|
|
const file = ledgerPath(root), entry = await entryAt(file);
|
|
if (!entry) return [];
|
|
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error('OTel ownership ledger must be a regular file');
|
|
const record = JSON.parse(await readFile(file, 'utf8'));
|
|
if (record.version !== 1 || record.serverDirectory !== root || !Array.isArray(record.links)
|
|
|| record.links.length > otelPeerNames.length || new Set(record.links.map(link => link.name)).size !== record.links.length
|
|
|| record.links.some(link => !otelPeerNames.includes(link.name) || typeof link.target !== 'string'
|
|
|| !path.isAbsolute(link.target) || path.resolve(link.target) !== link.target
|
|
|| !link.target.endsWith(path.join('node_modules', link.name)))) throw new Error('Invalid OTel ownership ledger');
|
|
return record.links;
|
|
}
|
|
async function writeOwnership(root, links) {
|
|
const file = ledgerPath(root);
|
|
if (!links.length) { await rm(file, { force: true }); return; }
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
try {
|
|
await writeFile(temporary, JSON.stringify({ version: 1, serverDirectory: root, links }, null, 2), { flag: 'wx' });
|
|
await rename(temporary, file);
|
|
} finally { await rm(temporary, { force: true }); }
|
|
}
|
|
async function ownsLink(destination, record) {
|
|
const entry = await entryAt(destination);
|
|
return Boolean(record && entry?.isSymbolicLink()
|
|
&& path.resolve(path.dirname(destination), await readlink(destination)) === record.target);
|
|
}
|
|
async function installWithNpm(directory) {
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn('npm', ['install', '--prefix', directory, '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline'], { stdio: 'inherit', shell: false });
|
|
child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`OTel fixture installation failed (${code})`)));
|
|
});
|
|
}
|
|
|
|
/** Only the exact recorded symlink may be removed; install prefixes retain evidence. */
|
|
export async function cleanupSleepOtelLinks({ serverDirectory = server } = {}) {
|
|
const root = path.resolve(serverDirectory), records = await readOwnership(root), remaining = [], removed = [], errors = [];
|
|
for (const record of records) {
|
|
const destination = path.join(root, 'node_modules', record.name);
|
|
if (!await entryAt(destination)) continue;
|
|
if (!await ownsLink(destination, record)) {
|
|
remaining.push(record); errors.push(new Error(`Refusing to clean unrelated ${record.name}`)); continue;
|
|
}
|
|
await unlink(destination); removed.push(record.name);
|
|
}
|
|
await writeOwnership(root, remaining);
|
|
if (errors.length) throw new AggregateError(errors, 'Some OTel destinations no longer match owned links');
|
|
return { removed, installPrefixes: [...new Set(records.map(record => path.dirname(path.dirname(path.dirname(record.target)))))], boundary: 'Only recorded matching symlinks removed; temporary install prefixes are not deleted' };
|
|
}
|
|
|
|
export async function setupSleepOtelPeers({ serverDirectory = server, temporaryRoot = tmpdir(), install = installWithNpm } = {}) {
|
|
const root = path.resolve(serverDirectory), manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
|
const owned = await readOwnership(root), missing = [];
|
|
for (const name of otelPeerNames) {
|
|
const exact = manifest.peerDependencies[name];
|
|
if (!/^\d+\.\d+\.\d+$/.test(exact ?? '')) throw new Error(`Exact existing peer version required for ${name}`);
|
|
const installed = await versionAt(root, name);
|
|
if (installed && installed !== exact) throw new Error(`Installed ${name} does not match ${exact}; refusing to replace it`);
|
|
if (installed) continue;
|
|
const destination = path.join(root, 'node_modules', name);
|
|
if (await entryAt(destination) && !await ownsLink(destination, owned.find(link => link.name === name))) {
|
|
throw new Error(`Refusing to replace unrelated ${name}; restore its target or verify ownership before removing the unrecorded entry`);
|
|
}
|
|
missing.push(name);
|
|
}
|
|
if (!missing.length) return { linked: [], alreadyInstalled: true };
|
|
const directory = await mkdtemp(path.join(temporaryRoot, 'paperclip-sleep-otel-'));
|
|
let linked = 0;
|
|
try {
|
|
await writeFile(path.join(directory, 'package.json'), JSON.stringify({ private: true, dependencies: Object.fromEntries(otelPeerNames.map(name => [name, manifest.peerDependencies[name]])) }, null, 2));
|
|
await install(directory);
|
|
const resolved = Object.fromEntries(await Promise.all(otelPeerNames.map(async name => [name, await versionAt(directory, name)])));
|
|
for (const name of otelPeerNames) if (resolved[name] !== manifest.peerDependencies[name]) throw new Error(`Resolved ${name} differs from pinned peer`);
|
|
const records = [...owned];
|
|
for (const name of missing) {
|
|
const destination = path.join(root, 'node_modules', name), previous = owned.find(record => record.name === name);
|
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
if (await entryAt(destination)) {
|
|
if (!await ownsLink(destination, previous)) throw new Error(`OTel destination changed; refusing to replace ${name}`);
|
|
await unlink(destination);
|
|
}
|
|
const target = path.join(directory, 'node_modules', name);
|
|
await symlink(target, destination, 'dir'); linked++;
|
|
const updated = [...records.filter(record => record.name !== name), { name, target }];
|
|
try { await writeOwnership(root, updated); }
|
|
catch (error) {
|
|
if (await ownsLink(destination, { name, target })) { await unlink(destination); linked--; }
|
|
throw error;
|
|
}
|
|
records.splice(0, records.length, ...updated);
|
|
}
|
|
const receipt = { version: 1, directory, packages: resolved, linked: missing, ownershipLedger: ledgerPath(root), cleanup: 'node scripts/sleep-qualification/setup-otel.mjs --cleanup' };
|
|
await writeFile(path.join(directory, 'qualification-setup.json'), JSON.stringify(receipt, null, 2));
|
|
return receipt;
|
|
} catch (error) {
|
|
// A prefix with published links stays available for explicit owned cleanup.
|
|
if (!linked) await rm(directory, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
const args = process.argv.slice(2);
|
|
if (args.length > 1 || (args.length && args[0] !== '--cleanup')) throw new Error('Usage: node scripts/sleep-qualification/setup-otel.mjs [--cleanup]');
|
|
const receipt = args[0] === '--cleanup' ? await cleanupSleepOtelLinks() : await setupSleepOtelPeers();
|
|
process.stdout.write(JSON.stringify(receipt) + '\n');
|
|
}
|