diff --git a/doc/benchmarks/2026-11-02-profile-projection.json b/doc/benchmarks/2026-11-02-profile-projection.json new file mode 100644 index 00000000000..428f514b0ae --- /dev/null +++ b/doc/benchmarks/2026-11-02-profile-projection.json @@ -0,0 +1,532 @@ +{ + "measurements": [ + { + "kind": "environment", + "node": "v24.22.2", + "platform": "darwin", + "arch": "arm64", + "build": "esbuild transpiled ES modules, node24 target; no runtime TypeScript loader", + "baseline": "1f8f2e195f4897cd1c237bdc068de95780ab0c3c", + "pending": "fd59c0183ce64c44696f79604516f4d51ca5662b", + "treatmentHead": "ca458baddc6461fb6c888984246e329e2bb4bdfe", + "sourceSha256": { + "profiles/projection": "fdb8b2cd220ce71a1ef322afef8d25e684beff88b73fedb1a5b73122b95e9a8d", + "profiles/service": "d56f83ec975bf4d2955cb9a31e9b4ebeb4f3872f28d930436bd37b6004ce1824", + "profiles/resolution": "cb621da4864ee143922c94644487b8bf53a185848a419e9b05945ed6b0237a14", + "catalog/attention": "c3fdc44e01f39b59cec047584c75d99df192f4e67809a6078ac75dfbb3c2b005" + }, + "forcedGc": true, + "fixture": { + "profiles": 200, + "activeTools": 1900, + "removedTools": 695, + "rules": 76000, + "agents": 12 + }, + "repeats": 5, + "caveat": "Local synthetic service workload, shared developer host; not hosted page-load acceptance" + }, + { + "kind": "correctness", + "owner": "profiles", + "completeOutputEquality": true + }, + { + "kind": "correctness", + "owner": "attention", + "completeOutputEquality": true + }, + { + "kind": "timing", + "round": 0, + "owner": "profiles", + "label": "baseline", + "wallMs": 3044.6725000000006, + "cpuMs": 3086.254, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 0, + "owner": "profiles", + "label": "pending", + "wallMs": 1331.632625000002, + "cpuMs": 1475.759, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 0, + "owner": "profiles", + "label": "combined", + "wallMs": 214.5939580000013, + "cpuMs": 384.783, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 0, + "owner": "attention", + "label": "baseline", + "wallMs": 2378.09475, + "cpuMs": 2466.076, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 0, + "owner": "attention", + "label": "pending", + "wallMs": 174.1545839999999, + "cpuMs": 319.323, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 0, + "owner": "attention", + "label": "combined", + "wallMs": 159.26837499999965, + "cpuMs": 219.179, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 1, + "owner": "profiles", + "label": "combined", + "wallMs": 181.5132080000003, + "cpuMs": 224.232, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 1, + "owner": "profiles", + "label": "pending", + "wallMs": 1997.141208000001, + "cpuMs": 1830.433, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 1, + "owner": "profiles", + "label": "baseline", + "wallMs": 4942.23775, + "cpuMs": 4553.281, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 1, + "owner": "attention", + "label": "combined", + "wallMs": 318.5823750000018, + "cpuMs": 466.636, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 1, + "owner": "attention", + "label": "pending", + "wallMs": 192.32737500000076, + "cpuMs": 254.286, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 1, + "owner": "attention", + "label": "baseline", + "wallMs": 2694.246791999998, + "cpuMs": 2761.568, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 2, + "owner": "profiles", + "label": "baseline", + "wallMs": 4083.2392089999994, + "cpuMs": 3864.952, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 2, + "owner": "profiles", + "label": "pending", + "wallMs": 1423.3007910000015, + "cpuMs": 1581.88, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 2, + "owner": "profiles", + "label": "combined", + "wallMs": 223.0350000000035, + "cpuMs": 414.014, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 2, + "owner": "attention", + "label": "baseline", + "wallMs": 1864.8400000000038, + "cpuMs": 1928.275, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 2, + "owner": "attention", + "label": "pending", + "wallMs": 175.5971669999999, + "cpuMs": 339.946, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 2, + "owner": "attention", + "label": "combined", + "wallMs": 147.17741699999897, + "cpuMs": 202.889, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 3, + "owner": "profiles", + "label": "combined", + "wallMs": 182.54275000000052, + "cpuMs": 218.628, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 3, + "owner": "profiles", + "label": "pending", + "wallMs": 1203.7079159999994, + "cpuMs": 1258.248, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 3, + "owner": "profiles", + "label": "baseline", + "wallMs": 2998.833458000001, + "cpuMs": 3164.455, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 3, + "owner": "attention", + "label": "combined", + "wallMs": 174.7018339999995, + "cpuMs": 353.529, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 3, + "owner": "attention", + "label": "pending", + "wallMs": 147.98641700000007, + "cpuMs": 204.736, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 3, + "owner": "attention", + "label": "baseline", + "wallMs": 2482.0454580000005, + "cpuMs": 2538.083, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 4, + "owner": "profiles", + "label": "baseline", + "wallMs": 2980.427125000002, + "cpuMs": 3091.659, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 4, + "owner": "profiles", + "label": "pending", + "wallMs": 1334.6162909999985, + "cpuMs": 1494.868, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 4, + "owner": "profiles", + "label": "combined", + "wallMs": 233.16475000000355, + "cpuMs": 439.851, + "queries": 7, + "rows": 78114, + "resultCount": 200 + }, + { + "kind": "timing", + "round": 4, + "owner": "attention", + "label": "baseline", + "wallMs": 2031.9052500000034, + "cpuMs": 2090.426, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 4, + "owner": "attention", + "label": "pending", + "wallMs": 173.21041600000171, + "cpuMs": 316.039, + "queries": 7, + "rows": 78101 + }, + { + "kind": "timing", + "round": 4, + "owner": "attention", + "label": "combined", + "wallMs": 158.0989999999947, + "cpuMs": 221.091, + "queries": 7, + "rows": 78101 + }, + { + "kind": "profile", + "owner": "profiles", + "label": "baseline", + "durationMs": 2994.209, + "samples": 2345 + }, + { + "kind": "profile", + "owner": "profiles", + "label": "pending", + "durationMs": 1304.667, + "samples": 999 + }, + { + "kind": "profile", + "owner": "profiles", + "label": "combined", + "durationMs": 271.542, + "samples": 173 + }, + { + "kind": "profile", + "owner": "attention", + "label": "baseline", + "durationMs": 2502.958, + "samples": 1958 + }, + { + "kind": "profile", + "owner": "attention", + "label": "pending", + "durationMs": 248.75, + "samples": 149 + }, + { + "kind": "profile", + "owner": "attention", + "label": "combined", + "durationMs": 216.375, + "samples": 129 + } + ], + "responsiveness": [ + { + "kind": "responsiveness", + "round": 0, + "owner": "profiles", + "label": "baseline", + "operationMs": 3020.712625, + "completedHealthRequests": 4, + "maxHealthMs": 2885.9768750000003, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 0, + "owner": "profiles", + "label": "combined", + "operationMs": 216.8745829999989, + "completedHealthRequests": 5, + "maxHealthMs": 78.3608750000003, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 0, + "owner": "attention", + "label": "baseline", + "operationMs": 2220.037542, + "completedHealthRequests": 4, + "maxHealthMs": 2103.2218329999996, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 0, + "owner": "attention", + "label": "combined", + "operationMs": 178.0747500000016, + "completedHealthRequests": 4, + "maxHealthMs": 55.81975000000057, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 1, + "owner": "profiles", + "label": "combined", + "operationMs": 180.5625, + "completedHealthRequests": 4, + "maxHealthMs": 63.572291000000405, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 1, + "owner": "profiles", + "label": "baseline", + "operationMs": 3155.7248339999987, + "completedHealthRequests": 5, + "maxHealthMs": 3027.892249999999, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 1, + "owner": "attention", + "label": "combined", + "operationMs": 183.60120800000004, + "completedHealthRequests": 4, + "maxHealthMs": 71.33616699999766, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 1, + "owner": "attention", + "label": "baseline", + "operationMs": 2413.9667499999996, + "completedHealthRequests": 3, + "maxHealthMs": 2292.3195000000014, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 2, + "owner": "profiles", + "label": "baseline", + "operationMs": 3146.798458000001, + "completedHealthRequests": 4, + "maxHealthMs": 3008.1585419999974, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 2, + "owner": "profiles", + "label": "combined", + "operationMs": 230.38120900000285, + "completedHealthRequests": 4, + "maxHealthMs": 83.7714589999996, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 2, + "owner": "attention", + "label": "baseline", + "operationMs": 1886.0740839999999, + "completedHealthRequests": 4, + "maxHealthMs": 1770.5739170000015, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + }, + { + "kind": "responsiveness", + "round": 2, + "owner": "attention", + "label": "combined", + "operationMs": 180.26520900000105, + "completedHealthRequests": 3, + "maxHealthMs": 64.22629199999938, + "errors": [], + "scope": "Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route" + } + ], + "responsiveness_limit": "Minimal local HTTP liveness handler on the same event loop as real database-backed service operations, not the assembled application health route." +} diff --git a/doc/mcp-discovery-performance.md b/doc/mcp-discovery-performance.md index ac696b01187..bed7a912efd 100644 --- a/doc/mcp-discovery-performance.md +++ b/doc/mcp-discovery-performance.md @@ -138,3 +138,86 @@ seven samples of ten resolutions. It reports actual query and returned-row counts, process CPU, and elapsed time. The installation dependency returns an empty list for this fixture, which has no installed connections. It does not measure provider work, whole-run latency, or hosted performance. + + +## Local profile comparison on 2026-11-02 + +The profile and app-attention paths were re-profiled together after +[#18681](https://github.com/paperclipai/paperclip/pull/18681) and +[#18673](https://github.com/paperclipai/paperclip/pull/18673). Both fixes replace +nested selector scans with indexes that live for one computation. + +The comparison used Node 24.22.2 on macOS arm64. Service modules were compiled +as ES modules for Node 24 before measurement. No TypeScript loader ran during +measurement. All variants used the same database, fixture, and database client. +Only the service and projection implementation changed between variants. +The fixture had 200 profiles, 1,900 active tools, 695 removed tools, 76,000 +rules, and 12 agents. Complete ordered responses matched before timing. + +Baseline: `1f8f2e195f4897cd1c237bdc068de95780ab0c3c`. The pending-review fix was +`fd59c0183ce64c44696f79604516f4d51ca5662b`. The summary fix was +`e20165b6eb871c29e504a3837e8fe92a17ef969f`; its later benchmark helper change +does not change production code. Five unprofiled samples alternated order, +with forced GC before each operation. Values below are medians. +[All samples](benchmarks/2026-11-02-profile-projection.json) retain each measurement +and the pending-review-only treatment. + +| Service | Baseline CPU | Combined CPU | Baseline elapsed | Combined elapsed | +| --- | ---: | ---: | ---: | ---: | +| `listProfiles` | 3,164 ms | 385 ms | 3,045 ms | 215 ms | +| `listAppsNeedingAttention` | 2,466 ms | 221 ms | 2,378 ms | 159 ms | + +Each version made seven queries per service. Profile listing returned 78,114 +database rows; app attention returned 78,101. These counts did not change. +The gains came from less JavaScript work after the reads. + +Separate Inspector CPU captures used a 1 ms sampling interval. The baseline +showed `profileCoversCatalogScope` and `summarizeProfile` as major CPU costs. +Their repeated scans were absent from the combined capture. Residual work +included pending projection, row conversion, and garbage collection. +Profile capture time was excluded from the timing samples above. + +The committed harness is in `server/scripts/profile-tool-projection/`. +Use Node 24.22.2, install workspace dependencies, and run these commands from +the repository root in a disposable worktree. The compile step writes local +`dist/` output. It is not the full application build. The Git history must +contain the baseline and pending-fix commits named above. + +```sh +git fetch https://github.com/paperclipai/paperclip.git refs/pull/18681/head +pnpm install --frozen-lockfile +node server/scripts/profile-tool-projection/compile.mjs +node --expose-gc --import ./server/scripts/profile-tool-projection/register.mjs server/scripts/profile-tool-projection/benchmark.mjs --output-dir /tmp/paperclip-tool-projection --repeat 5 +node --expose-gc --import ./server/scripts/profile-tool-projection/register.mjs server/scripts/profile-tool-projection/responsiveness.mjs --output-dir /tmp/paperclip-tool-projection +``` + +The compile step writes the baseline and pending-only owner modules beside +the current compiled modules. A build manifest records the current Git HEAD +and the SHA-256 hashes of the four current owner source files. The benchmark +records this manifest, validates complete output equality, then measures +`profiles` and `attention` separately for `baseline`, `pending`, and `combined`. +It writes `measurements.json` and six separate `.cpuprofile` files to the +output directory. The HTTP probe writes `responsiveness.json` there. Keep +CPU profiles private because they contain local source paths. Rebuild and +rerun if the owner files change. Serialize these commands with other heavy +builds and benchmarks on the same host. + +A separate process also sent simple HTTP requests every 20 ms while each +real service operation ran. The local liveness handler shared the service's +Node event loop. There was at most one outstanding probe request. Across +three alternating trials, the median largest probe delay fell from 3,008 ms +to 78 ms during profile listing, and from 2,103 ms to 64 ms during app +attention. All 48 probe requests completed without error. + +These are synthetic service measurements on a shared developer machine. +They are not a full application build, a browser test, or hosted acceptance. +The liveness handler is a minimal test handler, not the application's health +route. Workload, JIT warmup, and host activity affect absolute timings. The +comparison does not establish a page-load percentage or production capacity. +Use a matched deployment window to confirm the effect on user requests. + +Both benchmark scripts use the test database helper. Set +`PAPERCLIP_TEST_POSTGRES_ADMIN_URL` to use a local PostgreSQL server; otherwise +they create and remove an embedded test database. The helper creates a unique +disposable database and removes it afterward. Benchmark setup and fixture +insertion are outside the timing samples. diff --git a/server/scripts/benchmark-profile-review-projection.ts b/server/scripts/benchmark-profile-review-projection.ts index 694f3a25704..1c1265ec049 100644 --- a/server/scripts/benchmark-profile-review-projection.ts +++ b/server/scripts/benchmark-profile-review-projection.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { isDeepStrictEqual } from "node:util"; import { pathToFileURL } from "node:url"; -import { createDb, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db"; +import { createDb, type Db } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "../src/__tests__/helpers/embedded-postgres.js"; import { createToolProfileService } from "../src/services/tool-access/profiles/service.js"; import { createToolAppAttention } from "../src/services/tool-access/catalog/attention.js"; import { createProfileProjectionFixture } from "../src/__tests__/helpers/tool-profile-projection-fixture.js"; diff --git a/server/scripts/profile-tool-projection/benchmark.mjs b/server/scripts/profile-tool-projection/benchmark.mjs new file mode 100644 index 00000000000..f4a0ed3b176 --- /dev/null +++ b/server/scripts/profile-tool-projection/benchmark.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { writeFile, mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { Session } from 'node:inspector'; +import { createDb } from '@paperclipai/db'; +import { startEmbeddedPostgresTestDatabase } from '../../dist/__tests__/helpers/embedded-postgres.js'; +import { createProfileProjectionFixture } from '../../dist/__tests__/helpers/tool-profile-projection-fixture.js'; +const option = (name, fallback) => { const index = process.argv.indexOf(name); return index < 0 ? fallback : process.argv[index + 1] ?? fallback; }; +const output = path.resolve(option('--output-dir', '/tmp/paperclip-tool-projection')); +const repeats = Number(option('--repeat', '5')); +assert.ok(Number.isInteger(repeats) && repeats > 0, '--repeat must be a positive integer'); +assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc'); +const build = JSON.parse(await readFile(new URL('../../dist/.profile-projection-build.json', import.meta.url), 'utf8')); +await mkdir(output, { recursive: true }); +const variants = {}; +for (const label of ['baseline', 'pending', 'combined']) { + const p = label === 'combined' ? './service.js' : `./.${label}-service.js`; + const a = label === 'combined' ? './attention.js' : `./.${label}-attention.js`; + variants[label] = { + profileFactory: (await import(new URL(`../../dist/services/tool-access/profiles/${p}`, import.meta.url))).createToolProfileService, + attentionFactory: (await import(new URL(`../../dist/services/tool-access/catalog/${a}`, import.meta.url))).createToolAppAttention, + }; +} +const database = await startEmbeddedPostgresTestDatabase('paperclip-hotpaths-owned-'); +const db = createDb(database.connectionString, { maxConnections: 1 }); +let queries = 0, rows = 0; +const measuredDb = new Proxy(db, { get(target, key) { + if (key !== 'select') return Reflect.get(target, key); + return (...fields) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = (...args) => { + const selection = from(...args), execute = selection.execute.bind(selection); + selection.execute = async () => { const result = await execute(); queries++; rows += result.length; return result; }; + return selection; + }; + return builder; + }; +} }); +const records = []; +const emit = record => { records.push(record); console.log(JSON.stringify(record)); }; +try { + const fixture = await createProfileProjectionFixture(db); + const now = () => new Date('2026-02-01T00:00:00Z'); + for (const variant of Object.values(variants)) { + const profiles = variant.profileFactory(measuredDb, now); + const attention = variant.attentionFactory(measuredDb, now); + variant.profiles = () => profiles.listProfiles(fixture.company.id); + variant.attention = () => attention(fixture.company.id); + } + emit({ kind: 'environment', node: process.version, platform: process.platform, arch: process.arch, + build: 'esbuild transpiled ES modules, node24 target; no runtime TypeScript loader', + ...build, forcedGc: true, + fixture: { profiles: 200, activeTools: 1900, removedTools: 695, rules: 76000, agents: 12 }, repeats, + caveat: 'Local synthetic service workload, shared developer host; not hosted page-load acceptance' }); + for (const owner of ['profiles', 'attention']) { + const expected = await variants.baseline[owner](); + for (const label of ['pending', 'combined']) assert.deepEqual(await variants[label][owner](), expected); + emit({ kind: 'correctness', owner, completeOutputEquality: true }); + } + for (let round = 0; round < repeats; round++) { + for (const owner of ['profiles', 'attention']) { + const order = round % 2 ? ['combined', 'pending', 'baseline'] : ['baseline', 'pending', 'combined']; + for (const label of order) { + global.gc?.(); queries = 0; rows = 0; + const started = performance.now(), cpu = process.cpuUsage(); + const result = await variants[label][owner](); + const used = process.cpuUsage(cpu); + emit({ kind: 'timing', round, owner, label, wallMs: performance.now() - started, cpuMs: (used.user + used.system) / 1000, + queries, rows, resultCount: owner === 'profiles' ? result.length : result.items?.length }); + } + } + } + for (const owner of ['profiles', 'attention']) { + for (const label of ['baseline', 'pending', 'combined']) { + global.gc?.(); + const session = new Session(); session.connect(); + const post = (method, args = {}) => new Promise((resolve, reject) => session.post(method, args, (error, value) => error ? reject(error) : resolve(value))); + try { + await post('Profiler.enable'); await post('Profiler.setSamplingInterval', { interval: 1000 }); + await post('Profiler.start'); + await variants[label][owner](); + const { profile } = await post('Profiler.stop'); + await writeFile(`${output}/${owner}-${label}.cpuprofile`, JSON.stringify(profile)); + emit({ kind: 'profile', owner, label, durationMs: (profile.endTime - profile.startTime) / 1000, samples: profile.samples?.length }); + } finally { session.disconnect(); } + } + } +} finally { + try { + await writeFile(`${output}/measurements.json`, JSON.stringify(records, null, 2)); + } finally { + try { await db.$client.end({ timeout: 1 }); } + finally { await database.cleanup(); } + } +} diff --git a/server/scripts/profile-tool-projection/compile.mjs b/server/scripts/profile-tool-projection/compile.mjs new file mode 100644 index 00000000000..c82b2b8adf1 --- /dev/null +++ b/server/scripts/profile-tool-projection/compile.mjs @@ -0,0 +1,56 @@ +import { build, transform } from 'esbuild'; +import { readdir, mkdir, writeFile, cp, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +const root = fileURLToPath(new URL("../../..", import.meta.url)); +process.chdir(root); +async function sources(dir) { + const out = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory() && entry.name !== '__tests__') out.push(...await sources(file)); + else if (entry.isFile() && /\.tsx?$/.test(entry.name) && !/\.d\.ts$|\.test\.tsx?$/.test(entry.name)) out.push(file); + } + return out; +} +async function assets(dir, out) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name), target = path.join(out, entry.name); + if (entry.isDirectory() && entry.name !== '__tests__') await assets(file, target); + else if (entry.isFile() && entry.name.endsWith('.json')) { await mkdir(out, { recursive: true }); await cp(file, target); } + } +} +for (const pkg of ['packages/shared', 'packages/db', 'server']) { + const src = path.join(root, pkg, 'src'); + await build({ entryPoints: await sources(src), outbase: src, outdir: path.join(root, pkg, 'dist'), + format: 'esm', platform: 'node', target: 'node24', sourcemap: true, logLevel: 'warning' }); + await assets(src, path.join(root, pkg, 'dist')); +} +await cp('packages/db/src/migrations', 'packages/db/dist/migrations', { recursive: true }); +await cp('packages/shared/src/app-definitions', 'packages/shared/dist/app-definitions', { recursive: true }); +await mkdir('server/dist/__tests__/helpers', { recursive: true }); +await build({ entryPoints: ['server/src/__tests__/helpers/tool-profile-projection-fixture.ts', 'server/src/__tests__/helpers/embedded-postgres.ts'], + outbase: 'server/src/__tests__/helpers', outdir: 'server/dist/__tests__/helpers', format: 'esm', platform: 'node', target: 'node24', sourcemap: true }); +for (const [label, sha] of [['baseline', '1f8f2e195f4897cd1c237bdc068de95780ab0c3c'], ['pending', 'fd59c0183ce64c44696f79604516f4d51ca5662b']]) { + for (const [name, file] of [['projection', 'profiles/projection'], ['service', 'profiles/service'], ['attention', 'catalog/attention']]) { + const relative = `server/src/services/tool-access/${file}.ts`; + let code = execFileSync('git', ['show', `${sha}:${relative}`], { encoding: 'utf8', maxBuffer: 5e6 }); + if (name === 'service') code = code.replace('from "./projection.js"', `from "./.${label}-projection.js"`); + if (name === 'attention') code = code.replace('from "../profiles/projection.js"', `from "../profiles/.${label}-projection.js"`); + const outfile = `server/dist/services/tool-access/${path.dirname(file)}/.${label}-${name}.js`; + const result = await transform(code, { loader: 'ts', format: 'esm', platform: 'node', target: 'node24', sourcefile: relative, sourcemap: 'inline' }); + await writeFile(outfile, result.code); + } +} +const owners = ['profiles/projection', 'profiles/service', 'profiles/resolution', 'catalog/attention']; +const manifest = { + baseline: '1f8f2e195f4897cd1c237bdc068de95780ab0c3c', + pending: 'fd59c0183ce64c44696f79604516f4d51ca5662b', + treatmentHead: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + sourceSha256: Object.fromEntries(await Promise.all(owners.map(async file => [file, + createHash('sha256').update(await readFile(`server/src/services/tool-access/${file}.ts`)).digest('hex')]))), +}; +await writeFile('server/dist/.profile-projection-build.json', JSON.stringify(manifest, null, 2)); +console.log('Compiled baseline, pending-only, and current owning service modules; no runtime TypeScript loader.'); diff --git a/server/scripts/profile-tool-projection/probe.mjs b/server/scripts/profile-tool-projection/probe.mjs new file mode 100644 index 00000000000..41e43cbd9dd --- /dev/null +++ b/server/scripts/profile-tool-projection/probe.mjs @@ -0,0 +1,20 @@ +import http from 'node:http'; +const agent = new http.Agent({ keepAlive: true }); +let active = false, pending, times = [], port; +function ping() { + if (!active || pending) return; + pending = new Promise(resolve => { + const start = performance.now(); + const request = http.get({ host: '127.0.0.1', port, path: '/api/health', agent }, response => { + response.resume(); response.on('end', () => { times.push(performance.now() - start); resolve(); }); + }); + request.setTimeout(20000, () => request.destroy(new Error('Probe timeout'))); + request.on('error', error => { times.push({ error: error.message }); resolve(); }); + }).finally(() => { pending = null; }); +} +const interval = setInterval(ping, 20); +process.on('message', async message => { + if (message.kind === 'arm') { port = message.port; times = []; active = true; process.send({ kind: 'armed' }); } + if (message.kind === 'stop') { active = false; await pending; process.send({ kind: 'stopped', times }); } + if (message.kind === 'close') { clearInterval(interval); agent.destroy(); process.disconnect(); } +}); diff --git a/server/scripts/profile-tool-projection/register.mjs b/server/scripts/profile-tool-projection/register.mjs new file mode 100644 index 00000000000..3053c27df24 --- /dev/null +++ b/server/scripts/profile-tool-projection/register.mjs @@ -0,0 +1,13 @@ +import { registerHooks } from 'node:module'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import path from 'node:path'; +const root = fileURLToPath(new URL("../../..", import.meta.url)); +registerHooks({ resolve(specifier, context, next) { + for (const [name, dir] of [['@paperclipai/db', 'packages/db'], ['@paperclipai/shared', 'packages/shared']]) { + if (specifier === name || specifier.startsWith(name + '/')) { + const file = specifier === name ? 'index' : specifier.slice(name.length + 1); + return next(pathToFileURL(path.join(root, dir, 'dist', `${file}.js`)).href, context); + } + } + return next(specifier, context); +} }); diff --git a/server/scripts/profile-tool-projection/responsiveness.mjs b/server/scripts/profile-tool-projection/responsiveness.mjs new file mode 100644 index 00000000000..d38d4b754e5 --- /dev/null +++ b/server/scripts/profile-tool-projection/responsiveness.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createServer } from 'node:http'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { createDb } from '@paperclipai/db'; +import { startEmbeddedPostgresTestDatabase } from '../../dist/__tests__/helpers/embedded-postgres.js'; +import { createProfileProjectionFixture } from '../../dist/__tests__/helpers/tool-profile-projection-fixture.js'; +const index = process.argv.indexOf('--output-dir'); +const output = path.resolve(index < 0 ? '/tmp/paperclip-tool-projection' : process.argv[index + 1]); +assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc'); +await mkdir(output, { recursive: true }); +const database = await startEmbeddedPostgresTestDatabase('paperclip-hotpaths-http-owned-'); +const db = createDb(database.connectionString, { maxConnections: 1 }); +const server = createServer((_request, response) => { response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"status":"ok"}'); }); +let probe; +const control = async message => { + const reply = once(probe, 'message', { signal: AbortSignal.timeout(30000) }); + probe.send(message); + return (await reply)[0]; +}; +const records = []; +try { + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + probe = fork(new URL('./probe.mjs', import.meta.url), { execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'] }); + const fixture = await createProfileProjectionFixture(db); + const variants = {}, now = () => new Date('2026-02-01T00:00:00Z'); + for (const label of ['baseline', 'combined']) { + const p = label === 'combined' ? 'service' : '.baseline-service'; + const a = label === 'combined' ? 'attention' : '.baseline-attention'; + const profiles = (await import(new URL(`../../dist/services/tool-access/profiles/${p}.js`, import.meta.url))).createToolProfileService(db, now); + const attention = (await import(new URL(`../../dist/services/tool-access/catalog/${a}.js`, import.meta.url))).createToolAppAttention(db, now); + variants[label] = { profiles: () => profiles.listProfiles(fixture.company.id), attention: () => attention(fixture.company.id) }; + } + for (const variant of Object.values(variants)) for (const owner of ['profiles', 'attention']) await variant[owner](); + for (let round = 0; round < 3; round++) { + for (const owner of ['profiles', 'attention']) { + for (const label of round % 2 ? ['combined', 'baseline'] : ['baseline', 'combined']) { + global.gc?.(); + await control({ kind: 'arm', port: server.address().port }); + const start = performance.now(); + await variants[label][owner](); + const operationMs = performance.now() - start; + const result = await control({ kind: 'stop' }); + const good = result.times.filter(value => typeof value === 'number'); + const record = { kind: 'responsiveness', round, owner, label, operationMs, + completedHealthRequests: good.length, maxHealthMs: good.length ? Math.max(...good) : null, + errors: result.times.filter(value => typeof value !== 'number'), + scope: 'Separate-process HTTP probe to a minimal liveness handler in the same Node event loop as the real service; not the full application health route' }; + records.push(record); console.log(JSON.stringify(record)); + } + } + } +} finally { + try { + await writeFile(path.join(output, 'responsiveness.json'), JSON.stringify(records, null, 2)); + } finally { + try { + if (probe && probe.exitCode === null && probe.signalCode === null) { + const exited = once(probe, 'exit'); + if (probe.connected) probe.send({ kind: 'close' }); + else probe.kill(); + await exited; + } + } finally { + try { await new Promise(resolve => server.close(resolve)); } + finally { + try { await db.$client.end({ timeout: 1 }); } + finally { await database.cleanup(); } + } + } + } +}