mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Reconcile Claude platform packages with merged Codex defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/claude-runner-default: (85 commits) feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683) docs(skills): add slow-query diagnosis and fix skill (#16303) feat(sentry): add opt-in run failure reporting with saved run references (#16300) refactor(server): isolate provider message admission and runtime policies (#16289) refactor(server): isolate provider webhook lifecycle decisions (#16283) fix(workspaces): keep cleanup Git scans fresh and dedupe page reads (#16302) fix(sentry): keep provider-rejected Grok wakes out of run failure reports (#16297) fix(runner): report Runner disconnect age when a Dot bridge exits (#16296) refactor(server): isolate chat conversation and control policies (#16276) perf(recovery): use indexed execution identity lookups (#16291) perf(runs): bound history and reduce repeated context reads (#16286) fix(native-runtime): report which close barrier failed (#16298) feat(skills): select and validate tracked GitHub branches (#16288) fix(observability): sample native runs independently of triggers (#16285) fix(github): retry and recover review publication (#16284) perf(workspaces): wake terminal cleanup from committed changes (#16282) fix(mcp): deduplicate discovered tool descriptors (#16281) perf: remove history scans from task and chat queries (#16278) docs(skills): add maintenance bot setup workflow (#16279) fix(observability): time execution configuration steps (#16236) ... # Conflicts: # scripts/verify-grok-npm-install.mjs
This commit is contained in:
commit
b030683ca2
1176 files changed
+129461
-261897
No files matched your search
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from '../grok-public-install-sandbox.mjs';
|
||||
import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs';
|
||||
import { publicInstallClosure } from '../public-install-closure.mjs';
|
||||
import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
@@ -243,7 +244,7 @@ test('the image and Grok downloads start before staging and join without weakeni
|
||||
assert.ok(source.includes("const stagedGrok = join(prefetched, 'grok')"));
|
||||
assert.doesNotMatch(source, /provision-grok\.mjs'\), prerequisite\]/);
|
||||
// Both downloads start before any package is staged and before the first sandbox runs.
|
||||
const staging = position("visit('@paperclipai/server')");
|
||||
const staging = position('publicInstallClosure(repo)');
|
||||
const firstSandbox = position("isolated(['npm', 'install', '--ignore-scripts', '--omit=dev'");
|
||||
assert.ok(imagePull < staging && grokProvision < staging);
|
||||
assert.ok(position("await awaited('image', imageReady)") < firstSandbox);
|
||||
@@ -318,17 +319,8 @@ test('the verifier stages only workspace packages, so it skips the standalone bu
|
||||
const retention = readFileSync(new URL('../retain-runner-qualification-packages.mjs', import.meta.url), 'utf8');
|
||||
assert.ok(retention.includes("const pluginRelative = 'packages/plugins/sandbox-providers/daytona';") && retention.includes("cpSync(join(repo, pluginRelative, 'dist'), join(plugin, 'dist'), { recursive: true })"), 'retention still packs the Daytona plugin from its dist, which is why the gated build exists');
|
||||
const repo = new URL('../../', import.meta.url);
|
||||
const listing = spawnSync(process.execPath, [fileURLToPath(new URL('scripts/release-package-map.mjs', repo)), 'list'], { cwd: fileURLToPath(repo), encoding: 'utf8' });
|
||||
assert.equal(listing.status, 0, listing.stderr);
|
||||
const packages = new Map(listing.stdout.trim().split('\n').map(line => line.split('\t')).map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(new URL(`${dir}/package.json`, repo), 'utf8')) }]));
|
||||
const needed = new Set();
|
||||
const visit = name => {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `missing public workspace dependency ${name}`); needed.add(name);
|
||||
for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) if (spec.startsWith('workspace:')) visit(dep);
|
||||
};
|
||||
assert.ok(source.includes("visit('@paperclipai/server');\n visit('paperclipai');"));
|
||||
visit('@paperclipai/server'); visit('paperclipai');
|
||||
assert.ok(source.includes('const { packages, needed } = publicInstallClosure(repo);'));
|
||||
const { packages, needed } = publicInstallClosure(fileURLToPath(repo));
|
||||
const workspace = parseWorkspaceEntries(readFileSync(new URL('pnpm-workspace.yaml', repo), 'utf8'));
|
||||
const standalone = [...packages.values()].filter(({ dir }) => !isWorkspacePackage(dir, workspace)).map(({ dir }) => dir);
|
||||
assert.ok(standalone.length > 0, 'the public package map must still carry standalone packages for this check to mean anything');
|
||||
@@ -341,7 +333,7 @@ test('the verifier stages only workspace packages, so it skips the standalone bu
|
||||
assert.deepEqual(JSON.parse(readFileSync(new URL('server/package.json', repo), 'utf8')).files, ['dist', 'ui-dist', 'skills']);
|
||||
assert.ok(source.includes("PAPERCLIP_RELEASE_REUSE_UI_DIST: '1'"));
|
||||
const uiDist = position("run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo)");
|
||||
assert.ok(position("visit('paperclipai')") < gate && gate < uiDist && uiDist < position('timing.stage_ui_dist_ms =') && uiDist < position('const stageSource = '));
|
||||
assert.ok(position('publicInstallClosure(repo)') < gate && gate < uiDist && uiDist < position('timing.stage_ui_dist_ms =') && uiDist < position('const stageSource = '));
|
||||
});
|
||||
|
||||
test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PUBLIC_INSTALL_ROOTS, publicInstallClosure, publicRegistryDependencies } from '../public-install-closure.mjs';
|
||||
|
||||
const repo = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
test('the closure starts at the server and the CLI and follows only workspace specs', () => {
|
||||
assert.deepEqual(PUBLIC_INSTALL_ROOTS, ['@paperclipai/server', 'paperclipai']);
|
||||
const { packages, needed } = publicInstallClosure(repo);
|
||||
const order = [...needed];
|
||||
assert.equal(order[0], '@paperclipai/server');
|
||||
assert.ok(needed.has('paperclipai'));
|
||||
for (const name of needed) {
|
||||
const { manifest } = packages.get(name);
|
||||
for (const [dep, spec] of Object.entries({ ...manifest.dependencies, ...manifest.optionalDependencies })) {
|
||||
assert.equal(needed.has(dep), spec.startsWith('workspace:'), `${name} -> ${dep}@${spec}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the warm-up graph is the closure\'s registry dependencies, first spec wins', () => {
|
||||
const pkg = (dependencies, optionalDependencies) => ({ dir: 'x', manifest: { dependencies, optionalDependencies } });
|
||||
const packages = new Map([
|
||||
['a', pkg({ b: 'workspace:*', left: '^1.0.0', shared: '^2.0.0', local: 'file:../local', linked: 'link:../linked' }, { native: '1.2.3' })],
|
||||
['b', pkg({ shared: '^3.0.0', right: '~4.0.0' }, { left: '^9.0.0' })],
|
||||
['unvisited', pkg({ never: '1.0.0' })],
|
||||
]);
|
||||
assert.deepEqual(publicRegistryDependencies({ packages, needed: new Set(['a', 'b']) }), {
|
||||
dependencies: { left: '^1.0.0', shared: '^2.0.0', right: '~4.0.0' },
|
||||
optionalDependencies: { native: '1.2.3' },
|
||||
});
|
||||
const real = publicRegistryDependencies(publicInstallClosure(repo));
|
||||
const names = [...Object.keys(real.dependencies), ...Object.keys(real.optionalDependencies)];
|
||||
assert.ok(names.length > 0);
|
||||
for (const spec of [...Object.values(real.dependencies), ...Object.values(real.optionalDependencies)]) {
|
||||
assert.doesNotMatch(spec, /^(workspace|file|link):/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the warm-up refuses to run without the cache the verifier will read', () => {
|
||||
const env = { ...process.env };
|
||||
delete env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE;
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../warm-public-install-npm-cache.mjs', import.meta.url))], { env, encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE/);
|
||||
});
|
||||
|
||||
test('the Build lane warms the cache before pnpm build and hands the same directory to the verifier', () => {
|
||||
const workflow = readFileSync(new URL('../../.github/workflows/pr-trusted.yml', import.meta.url), 'utf8');
|
||||
const build = workflow.slice(workflow.indexOf('\n build:\n'), workflow.indexOf('\n verify_serialized_server:\n'));
|
||||
const position = fragment => { const index = build.indexOf(fragment); assert.notEqual(index, -1, `Build job must contain ${fragment}`); return index; };
|
||||
const warm = position('PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE="$RUNNER_TEMP/public-install-npm-cache" node scripts/warm-public-install-npm-cache.mjs');
|
||||
assert.ok(position('- name: Install dependencies') < warm && warm < position('- name: Build\n'), 'the warm-up overlaps the Rust restore and pnpm build');
|
||||
assert.ok(build.includes('set +e\n'), 'a failed warm-up still records its status, so the verifier never waits it out');
|
||||
const verify = position('export PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE="$RUNNER_TEMP/public-install-npm-cache"');
|
||||
assert.ok(verify < position('node scripts/verify-grok-npm-install.mjs'));
|
||||
const verifier = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.ok(verifier.includes("const cache = process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? resolve(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE) : join(root, 'cache');"));
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { createServer } from "node:http";
|
||||
import { canaryStartup } from "./canary-startup-fixture.mjs";
|
||||
import { canRetryPublishedTarball404, TARBALL_METADATA_TIMEOUT_MS } from "../../tests/canary-onboarding/start-published-canary.mjs";
|
||||
@@ -307,8 +308,20 @@ test("published canaries are gated by the exact-version onboarding browser smoke
|
||||
);
|
||||
assert.match(
|
||||
releaseWorkflow,
|
||||
/smoke_canary_onboarding:\n\s+needs: publish_canary\n\s+if: needs\.publish_canary\.result == 'success'/,
|
||||
/smoke_canary_onboarding:\n\s+needs: publish_canary\n\s+if: \$\{\{ !cancelled\(\) && needs\.publish_canary\.result == 'success' \}\}/,
|
||||
);
|
||||
const smoke = releaseWorkflow.split(" smoke_canary_onboarding:\n")[1].split(/\n [a-z_]+:\n/)[0];
|
||||
const condition = smoke.match(/^ if: \$\{\{ (.+) \}\}$/m)?.[1];
|
||||
assert.match(condition, /^!cancelled\(\) && needs\.publish_canary\.result == 'success'$/);
|
||||
for (const [result, cancelled, expected] of [
|
||||
["success", false, true], ["failure", false, false],
|
||||
["cancelled", false, false], ["skipped", false, false], ["success", true, false],
|
||||
]) {
|
||||
const needs = { publish_canary: { result }, release_runner_source: { result: "success" },
|
||||
select_nightly: { result: "skipped" }, select_beta: { result: "skipped" }, preflight_stable: { result: "skipped" } };
|
||||
assert.equal(runInNewContext(condition, { cancelled: () => cancelled, needs }), expected,
|
||||
`Canary smoke: publisher=${result}, cancelled=${cancelled}`);
|
||||
}
|
||||
assert.match(
|
||||
releaseWorkflow,
|
||||
/PAPERCLIPAI_VERSION: \$\{\{ needs\.publish_canary\.outputs\.canary_version \}\}/,
|
||||
@@ -772,6 +785,31 @@ test("release daemon production uses exact-source native hosts and no publisher
|
||||
assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`));
|
||||
}
|
||||
const release = readWorkflow("release.yml");
|
||||
const source = release.split(" release_runner_source:\n")[1].split(/\n [a-z_]+:\n/)[0];
|
||||
const binaries = release.split(" release_runner_binaries:\n")[1].split(/\n [a-z_]+:\n/)[0];
|
||||
assert.match(source, /needs: \[verify_canary, select_nightly, select_beta, preflight_stable\]/);
|
||||
assert.match(source, /!cancelled\(\)/);
|
||||
assert.match(binaries, /needs: release_runner_source/);
|
||||
assert.match(binaries, /ref: \$\{\{ needs\.release_runner_source\.outputs\.sha \}\}/);
|
||||
assert.match(binaries, /runner_binaries_only: true/);
|
||||
const condition = binaries.match(/^ if: (.+)$/m)?.[1].replace(/^\$\{\{\s*|\s*\}\}$/g, "");
|
||||
assert.match(condition, /^!cancelled\(\) && needs\.release_runner_source\.result == 'success' && needs\.release_runner_source\.outputs\.required == 'true'$/,
|
||||
"Native compilation must bypass deliberately skipped channel ancestors and require successful exact-source selection");
|
||||
const laneJobs = ["verify_canary", "select_nightly", "select_beta", "preflight_stable"];
|
||||
for (const activeLane of laneJobs) {
|
||||
for (const [result, required, cancelled, expected] of [
|
||||
["success", "true", false, true], ["failure", "true", false, false],
|
||||
["cancelled", "true", false, false], ["skipped", "true", false, false],
|
||||
["success", "false", false, false], ["success", undefined, false, false],
|
||||
["success", "true", true, false],
|
||||
]) {
|
||||
const needs = Object.fromEntries(laneJobs.map(job => [job, { result: job === activeLane ? "success" : "skipped" }]));
|
||||
needs.release_runner_source = { result, outputs: { required } };
|
||||
assert.equal(Object.values(needs).filter(job => job.result === "skipped").length, result === "skipped" ? 4 : 3);
|
||||
assert.equal(runInNewContext(condition, { cancelled: () => cancelled, needs }), expected,
|
||||
`${activeLane}: source=${result}, required=${required}, cancelled=${cancelled}`);
|
||||
}
|
||||
}
|
||||
for (const job of ["publish_canary", "publish_nightly", "publish_beta", "preview_stable", "publish_stable"]) {
|
||||
const block = release.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0];
|
||||
assert.match(block, /needs: .*release_runner_binaries/);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { companies, agents, issues, heartbeatRuns, issueRecoveryActions, createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
||||
import * as conversation from "../server/src/services/conversation-continuation.js";
|
||||
import * as blocker from "../server/src/services/execution-blocker.js";
|
||||
import * as delivery from "../server/src/services/execution-status-delivery.js";
|
||||
type Query = { sql: string; params: unknown[] };
|
||||
const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15) ?? "origin/master";
|
||||
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let baselineDir: string | undefined;
|
||||
let db: Db | undefined;
|
||||
try {
|
||||
database = await startEmbeddedPostgresTestDatabase("recovery-query-benchmark-");
|
||||
db = createDb(database.connectionString);
|
||||
baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".recovery-baseline-"));
|
||||
async function loadBaseline(name: string) {
|
||||
const modulePath = `server/src/services/${name}.ts`;
|
||||
const original = new URL(`../${modulePath}`, import.meta.url);
|
||||
const source = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { encoding: "utf8" });
|
||||
const rewritten = source.replace(/from "(\.{1,2}\/[^\"]+)"/g,
|
||||
(_match, specifier: string) => `from ${JSON.stringify(name === "execution-blocker" && specifier === "./conversation-continuation.js" ? path.join(baselineDir!, "conversation-continuation.ts") : fileURLToPath(new URL(specifier, original)))}`);
|
||||
const target = path.join(baselineDir!, `${name}.ts`);
|
||||
await fs.writeFile(target, rewritten);
|
||||
return import(pathToFileURL(target).href);
|
||||
}
|
||||
const oldConversation = await loadBaseline("conversation-continuation") as typeof conversation;
|
||||
const oldBlocker = await loadBaseline("execution-blocker") as typeof blocker;
|
||||
const oldDelivery = await loadBaseline("execution-status-delivery") as typeof delivery;
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), otherIssueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Synthetic recovery", issuePrefix: "BENCH" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "codex_local" });
|
||||
await db.insert(issues).values([{ id: issueId, companyId, title: "Target" }, { id: otherIssueId, companyId, title: "Other" }]);
|
||||
const prompt = randomBytes(144 * 1024).toString("base64");
|
||||
await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, runtime_mode, native_issue_id, created_at,
|
||||
context_snapshot, runner_profile_json, process_pid, error_code, execution_status_delivery_id)
|
||||
SELECT $1::uuid, $2::uuid, 'failed', 'legacy', case when n = 2 then $3::uuid when n = 3 then $4::uuid end,
|
||||
now() + n * interval '1 millisecond', jsonb_build_object('issueId', case when n in (1,3) then $3::text else $4::text end, 'prompt', $5::text),
|
||||
'{"adapterDispatch":{"adapterType":"codex_local"}}'::jsonb, 999999999, 'process_lost',
|
||||
case when n <= 100 then gen_random_uuid() end
|
||||
FROM generate_series(1, 1400) n`, [companyId, agentId, issueId, otherIssueId, prompt]);
|
||||
const runs = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).orderBy(heartbeatRuns.createdAt);
|
||||
await db.insert(issueRecoveryActions).values(runs.slice(0, 350).map(run => ({ companyId, sourceIssueId: issueId,
|
||||
kind: "active_run_watchdog", status: "resolved", ownerType: "board", cause: "legacy_execution_requires_reconciliation",
|
||||
fingerprint: run.id, evidence: { runId: run.id, automaticRecovery: { replay: "blocked" }, workspaceRestoreFailure: "restore_unsafe_archive" }, updatedAt: new Date(Date.UTC(2026, 0, 1) + runs.indexOf(run) * 1000), nextAction: "Fixture" })));
|
||||
await db.$client.unsafe("ANALYZE");
|
||||
async function capture(invoke: (captureDb: Db) => Promise<unknown>, actionQuery = false): Promise<Query> {
|
||||
let query: Query | undefined;
|
||||
const stop = new Error("captured");
|
||||
const captureDb = new Proxy(db!, { get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const selection = from(...args);
|
||||
selection.execute = async () => {
|
||||
const candidate = selection.toSQL();
|
||||
if (actionQuery && !candidate.sql.includes('from "issue_recovery_actions"')) return [];
|
||||
query = candidate; throw stop;
|
||||
};
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
}});
|
||||
try { await invoke(captureDb); } catch (error) { if (error !== stop) throw error; }
|
||||
assert.ok(query);
|
||||
return query;
|
||||
}
|
||||
async function measure(query: Query) {
|
||||
const samples: number[] = []; let rows: unknown[] = [];
|
||||
for (let n = 0; n < 7; n++) {
|
||||
const start = performance.now();
|
||||
rows = await db!.$client.unsafe(query.sql, query.params as never[]).values();
|
||||
if (n > 1) samples.push(performance.now() - start);
|
||||
}
|
||||
const [plan] = await db!.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]);
|
||||
samples.sort((a, b) => a - b);
|
||||
return { medianMs: samples[2], rows, plan: plan["QUERY PLAN"] };
|
||||
}
|
||||
const pairs = [
|
||||
{ name: "ownership", before: (d: Db) => oldConversation.getConversationOwnershipBlocker(d, companyId, issueId),
|
||||
after: (d: Db) => conversation.getConversationOwnershipBlocker(d, companyId, issueId) },
|
||||
{ name: "recoveryActions", before: (d: Db) => oldBlocker.getExecutionBlocker(d, companyId, issueId),
|
||||
after: (d: Db) => blocker.getExecutionBlocker(d, companyId, issueId) },
|
||||
{ name: "statusDelivery", before: (d: Db) => oldDelivery.deliverExecutionStatuses(d), after: (d: Db) => delivery.deliverExecutionStatuses(d) },
|
||||
];
|
||||
const results = [];
|
||||
for (const pair of pairs) {
|
||||
const before = await measure(await capture(pair.before, pair.name === "recoveryActions")), after = await measure(await capture(pair.after, pair.name === "recoveryActions"));
|
||||
const normalize = (rows: unknown[]) => rows.map(row => JSON.stringify(row)).sort();
|
||||
assert.deepEqual(normalize(after.rows), normalize(before.rows));
|
||||
results.push({ name: pair.name, equalRows: after.rows.length, baselineMs: before.medianMs, treatmentMs: after.medianMs,
|
||||
speedup: before.medianMs / after.medianMs, baselinePlan: before.plan, treatmentPlan: after.plan });
|
||||
}
|
||||
console.log(JSON.stringify({ baselineRevision, runs: 1400, actions: 350, promptBytes: Buffer.byteLength(prompt), results }, null, 2));
|
||||
} finally {
|
||||
try { await db?.$client.end({ timeout: 0 }); await database?.cleanup();
|
||||
if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true });
|
||||
} finally { clearInterval(keepAlive); }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { companies, agents, createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
||||
import { createHeartbeatRunState } from "../server/src/services/heartbeat/run-state.js";
|
||||
|
||||
type Query = { sql: string; params: unknown[] };
|
||||
const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15) ?? "origin/master";
|
||||
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let baselineDir: string | undefined;
|
||||
let db: Db | undefined;
|
||||
try {
|
||||
database = await startEmbeddedPostgresTestDatabase("run-history-benchmark-");
|
||||
db = createDb(database.connectionString);
|
||||
const modulePath = "server/src/services/heartbeat/run-state.ts";
|
||||
const moduleUrl = new URL(`../${modulePath}`, import.meta.url);
|
||||
const oldSource = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { encoding: "utf8" });
|
||||
const rewritten = oldSource.replace(/from "(\.{1,2}\/[^\"]+)"/g,
|
||||
(_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, moduleUrl)))}`);
|
||||
baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".run-history-baseline-"));
|
||||
const baselineFile = path.join(baselineDir, "run-state.ts");
|
||||
await fs.writeFile(baselineFile, rewritten);
|
||||
const baseline = await import(pathToFileURL(baselineFile).href) as typeof import("../server/src/services/heartbeat/run-state.js");
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Synthetic history", issuePrefix: "BENCH" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "codex_local" });
|
||||
const prompt = randomBytes(144 * 1024).toString("base64");
|
||||
await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, created_at, context_snapshot, usage_json, result_json)
|
||||
SELECT $1::uuid, $2::uuid, 'succeeded', now() + n * interval '1 millisecond',
|
||||
jsonb_build_object('issueId', 'synthetic-issue-' || n, 'taskId', 'task-' || n, 'taskKey', 'key-' || n,
|
||||
'commentId', 'comment-' || n, 'wakeCommentId', 'wake-' || n, 'wakeReason', 'assignment',
|
||||
'wakeSource', 'automation', 'wakeTriggerDetail', 'fixture', 'prompt', $3::text),
|
||||
'{"inputTokens":123,"cachedInputTokens":12,"outputTokens":45,"totalTokens":168}'::jsonb,
|
||||
'{"summary":"Synthetic result","costUsd":0.03}'::jsonb
|
||||
FROM generate_series(1, 1400) n`, [companyId, agentId, prompt]);
|
||||
await db.$client.unsafe("ANALYZE heartbeat_runs");
|
||||
|
||||
async function capture(factory: typeof createHeartbeatRunState, summary: boolean): Promise<Query> {
|
||||
let query: Query | undefined;
|
||||
const stop = new Error("captured");
|
||||
const captureDb = new Proxy(db!, { get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const selection = from(...args);
|
||||
selection.execute = async () => { query = selection.toSQL(); throw stop; };
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
}});
|
||||
try { await factory(captureDb).listRuns(companyId, undefined, 200, { summary }); }
|
||||
catch (error) { if (error !== stop) throw error; }
|
||||
assert.ok(query);
|
||||
return query;
|
||||
}
|
||||
async function measure(query: Query) {
|
||||
const samples: number[] = [];
|
||||
let rows: unknown[] = [];
|
||||
for (let n = 0; n < 7; n++) {
|
||||
const start = performance.now();
|
||||
rows = await db!.$client.unsafe(query.sql, query.params as never[]).values();
|
||||
if (n > 1) samples.push(performance.now() - start);
|
||||
}
|
||||
const [plan] = await db!.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]);
|
||||
samples.sort((a, b) => a - b);
|
||||
return { medianMs: samples[2], rows, plan: plan["QUERY PLAN"] };
|
||||
}
|
||||
const results = [];
|
||||
for (const summary of [true, false]) {
|
||||
const before = await measure(await capture(baseline.createHeartbeatRunState, summary));
|
||||
const after = await measure(await capture(createHeartbeatRunState, summary));
|
||||
assert.deepEqual(after.rows, before.rows);
|
||||
results.push({ summary, equalRows: after.rows.length, baselineMs: before.medianMs,
|
||||
treatmentMs: after.medianMs, speedup: before.medianMs / after.medianMs,
|
||||
baselinePlan: before.plan, treatmentPlan: after.plan });
|
||||
}
|
||||
console.log(JSON.stringify({ baselineRevision, runs: 1400, promptBytes: Buffer.byteLength(prompt), results }, null, 2));
|
||||
} finally {
|
||||
try {
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await database?.cleanup();
|
||||
if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true });
|
||||
} finally { clearInterval(keepAlive); }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/** Disposable PostgreSQL repro. Run: pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-slow-queries.ts */
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
const { and, eq, or, sql } = createRequire(new URL("../server/package.json", import.meta.url))("drizzle-orm");
|
||||
import {
|
||||
agents, agentWakeupRequests, chatActions, chatConversations, chatEndpoints,
|
||||
companies, createDb, heartbeatRuns, issues, startEmbeddedPostgresTestDatabase,
|
||||
toolApplications, toolConnections, type Db,
|
||||
} from "../packages/db/src/index.js";
|
||||
import { issueService } from "../server/src/services/issues.js";
|
||||
import { issueReadSqlCondition } from "../server/src/services/authorization.js";
|
||||
import { createInboundWakeNotices } from "../server/src/services/chat-channels/inbound/wakeup-notices.js";
|
||||
|
||||
console.error("[benchmark] imports complete");
|
||||
|
||||
const migration = await fs.readFile(new URL("../packages/db/src/migrations/0345_luxuriant_carmella_unuscione.sql", import.meta.url), "utf8");
|
||||
const indexes = migration.split("--> statement-breakpoint").map((statement) => statement.trim()).filter(Boolean);
|
||||
const baselineRef = process.argv.find((arg) => arg.startsWith("--baseline-ref="))?.slice("--baseline-ref=".length) ?? "origin/master";
|
||||
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
||||
// The database helper uses unreferenced retry/cleanup timers. A standalone
|
||||
// benchmark must stay alive until startup succeeds or reports its real error.
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
const database = await startEmbeddedPostgresTestDatabase("slow-query-benchmark-");
|
||||
console.error("[benchmark] database ready");
|
||||
const db = createDb(database.connectionString);
|
||||
let baselineModuleDir: string | undefined;
|
||||
type Query = { sql: string; params: unknown[] };
|
||||
|
||||
// Capture the real service SQL before execution; never dispatch notices or runs.
|
||||
async function captureQuery(invoke: (captureDb: Db) => Promise<unknown>): Promise<Query> {
|
||||
let captured: Query | undefined;
|
||||
const stop = new Error("query captured");
|
||||
const captureDb = new Proxy(db, {
|
||||
get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = db.select(...fields);
|
||||
const from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const query = from(...args);
|
||||
query.execute = async () => { captured = query.toSQL(); throw stop; };
|
||||
return query;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
},
|
||||
});
|
||||
try { await invoke(captureDb); } catch (error) { if (error !== stop) throw error; }
|
||||
assert.ok(captured, "service must produce a SQL query");
|
||||
return captured;
|
||||
}
|
||||
|
||||
async function measure(query: Query) {
|
||||
const samples: number[] = [];
|
||||
let rows: unknown[] = [];
|
||||
for (let sample = 0; sample < 7; sample++) {
|
||||
const start = performance.now();
|
||||
// Positional rows retain every column, including unnamed SQL projections
|
||||
// whose duplicate names would overwrite one another in object results.
|
||||
rows = await db.$client.unsafe(query.sql, query.params as never[]).values();
|
||||
if (sample > 1) samples.push(performance.now() - start);
|
||||
}
|
||||
const [explain] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]);
|
||||
samples.sort((a, b) => a - b);
|
||||
return { medianMs: samples[2], rows, explain: explain["QUERY PLAN"] };
|
||||
}
|
||||
|
||||
try {
|
||||
// Load only the old query module against current dependencies. Keep it in a
|
||||
// temporary server folder so bare package imports resolve within the worktree.
|
||||
const noticeModulePath = "server/src/services/chat-channels/inbound/wakeup-notices.ts";
|
||||
const noticeModuleUrl = new URL(`../${noticeModulePath}`, import.meta.url);
|
||||
const oldModule = execFileSync("git", ["show", `${baselineRevision}:${noticeModulePath}`], { encoding: "utf8" });
|
||||
const rewritten = oldModule.replace(/from "(\.{1,2}\/[^\"]+)"/g,
|
||||
(_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, noticeModuleUrl)))}`);
|
||||
baselineModuleDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".slow-query-baseline-"));
|
||||
const baselineModuleFile = path.join(baselineModuleDir, "wakeup-notices.ts");
|
||||
await fs.writeFile(baselineModuleFile, rewritten);
|
||||
const { createInboundWakeNotices: baselineNotices } = await import(pathToFileURL(baselineModuleFile).href) as typeof import("../server/src/services/chat-channels/inbound/wakeup-notices.js");
|
||||
console.error("[benchmark] baseline module ready");
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const sourceId = randomUUID();
|
||||
const endpointId = randomUUID();
|
||||
const conversationId = randomUUID();
|
||||
const applicationId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Query benchmark", issuePrefix: "BENCH" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", role: "engineer", adapterType: "codex_local" });
|
||||
await db.insert(issues).values({ id: sourceId, companyId, title: "Source", identifier: "BENCH-1", assigneeAgentId: agentId });
|
||||
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Fixture", type: "native" });
|
||||
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Fixture", uid: connectionId, transport: "chat_sdk", connectionPurpose: "channel" });
|
||||
await db.insert(chatEndpoints).values({ id: endpointId, companyId, connectionId, provider: "slack", publicId: endpointId, assignedAgentId: agentId, status: "active" });
|
||||
await db.insert(chatConversations).values({ id: conversationId, companyId, endpointId, issueId: sourceId, externalConversationId: "fixture", externalThreadId: "fixture", externalLabel: "Fixture", state: "waiting" });
|
||||
|
||||
// Similar row counts to the investigation. Every context has 192 KiB of
|
||||
// incompressible synthetic prompt text, so JSON filtering must visit TOAST.
|
||||
const prompt = randomBytes(144 * 1024).toString("base64");
|
||||
await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, native_issue_id, created_at, context_snapshot)
|
||||
SELECT $1::uuid, $2::uuid, 'succeeded', CASE WHEN n = 3 THEN $3::uuid END, now() + n * interval '1 millisecond',
|
||||
jsonb_build_object('prompt', $4::text) || CASE
|
||||
WHEN n = 1 THEN jsonb_build_object('issueId', $3::text, 'paperclipSecretRedactions', jsonb_build_array(jsonb_build_object('fixture', 'top-level')))
|
||||
WHEN n = 2 THEN jsonb_build_object('paperclipIssue', jsonb_build_object('id', $3::text), 'paperclipSecretRedactions', jsonb_build_array(jsonb_build_object('fixture', 'nested')))
|
||||
WHEN n = 3 THEN jsonb_build_object('issueId', gen_random_uuid()::text)
|
||||
WHEN n = 4 THEN jsonb_build_object('taskId', $3::text)
|
||||
WHEN n = 5 THEN jsonb_build_object('taskKey', 'BENCH-1')
|
||||
ELSE jsonb_build_object('issueId', gen_random_uuid()::text) END
|
||||
FROM generate_series(1, 1400) n`, [companyId, agentId, sourceId, prompt]);
|
||||
const originatingRuns = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).limit(5);
|
||||
await db.insert(issues).values(Array.from({ length: 334 }, (_, n) => ({
|
||||
companyId, title: `Candidate ${n}`, createdByUserId: "fixture",
|
||||
originRunId: originatingRuns[n % originatingRuns.length].id,
|
||||
})));
|
||||
const wakes = Array.from({ length: 13000 }, (_, n) => ({
|
||||
id: randomUUID(), companyId, agentId, source: "chat", status: n < 50 ? "deferred_issue_execution" : "succeeded",
|
||||
}));
|
||||
for (let offset = 0; offset < wakes.length; offset += 500) await db.insert(agentWakeupRequests).values(wakes.slice(offset, offset + 500));
|
||||
await db.insert(chatActions).values(wakes.slice(0, 377).map((wake, n) => ({
|
||||
id: wake.id, companyId, endpointId, conversationId, kind: "inbound_wakeup", providerActionId: wake.id,
|
||||
status: "processed", payload: n % 3 === 0 ? { coalesced: true } : {},
|
||||
})));
|
||||
// Cover coalesced ownership as well as ordinary receipts.
|
||||
for (let n = 1; n < 50; n += 2) await db.update(agentWakeupRequests)
|
||||
.set({ payload: { coalescedIntoWakeupRequestId: wakes[n - 1].id } }).where(eq(agentWakeupRequests.id, wakes[n].id));
|
||||
for (const [n, ownerId] of [[51, "not-a-uuid"], [52, wakes[0].id.toUpperCase()], [53, ""], [54, null]] as const) {
|
||||
await db.update(agentWakeupRequests).set({ payload: { coalescedIntoWakeupRequestId: ownerId } }).where(eq(agentWakeupRequests.id, wakes[n].id));
|
||||
}
|
||||
const noticeQuery = (factory: typeof createInboundWakeNotices) => captureQuery(async (captureDb) => factory(captureDb, {
|
||||
isShuttingDown: () => false, enqueueFailedChatRetryPublications: async () => 0,
|
||||
} as never).enqueueInboundWakeupPublications(200));
|
||||
const notice = await noticeQuery(baselineNotices);
|
||||
const fixedNotice = await noticeQuery(createInboundWakeNotices);
|
||||
const list = await captureQuery(async (captureDb) => issueService(captureDb).list(companyId, {
|
||||
createdFromIssueId: sourceId, sortField: "id", sortDir: "asc", limit: 200,
|
||||
readCondition: await issueReadSqlCondition(db, { type: "board", userId: "fixture", source: "cloud_tenant" }),
|
||||
}));
|
||||
const redactions = db.select({ contextSnapshot: sql`jsonb_build_object('paperclipSecretRedactions', ${heartbeatRuns.contextSnapshot}->'paperclipSecretRedactions')` })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), or(
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${sourceId}`,
|
||||
sql`${heartbeatRuns.contextSnapshot}->'paperclipIssue'->>'id' = ${sourceId}`,
|
||||
))).toSQL();
|
||||
|
||||
// The summary history query projects these eight context fields. Keep its
|
||||
// projection as a control: these indexes do not remove per-row JSON work.
|
||||
const runHistory = db.select({ id: heartbeatRuns.id,
|
||||
contextIssueId: sql`coalesce(${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId')`,
|
||||
...Object.fromEntries(["taskId", "taskKey", "commentId", "wakeCommentId", "wakeReason", "wakeSource", "wakeTriggerDetail"]
|
||||
.map((key) => [key, sql`${heartbeatRuns.contextSnapshot}->>${key}`])),
|
||||
}).from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)).orderBy(sql`${heartbeatRuns.createdAt} desc`).limit(200).toSQL();
|
||||
const queries = { chatNotice: notice, createdFromIssueList: list, issueRedactions: redactions, runHistory };
|
||||
// Remove only these candidate indexes from this disposable fixture. This
|
||||
// also permits repeatable comparisons after the migration is installed.
|
||||
for (const definition of indexes) await db.$client.unsafe(`DROP INDEX IF EXISTS ${definition.match(/"([^"]+)"/)![0]}`);
|
||||
await db.$client.unsafe("ANALYZE");
|
||||
const baseline: Record<string, Awaited<ReturnType<typeof measure>>> = {};
|
||||
for (const [name, query] of Object.entries(queries)) baseline[name] = await measure(query);
|
||||
for (const definition of indexes) await db.$client.unsafe(definition);
|
||||
await db.$client.unsafe("ANALYZE");
|
||||
const results = [];
|
||||
for (const [name, query] of Object.entries(queries)) {
|
||||
const treatment = await measure(name === "chatNotice" ? fixedNotice : query);
|
||||
// Unordered SQL returns a set, not a guaranteed order. Compare full rows.
|
||||
const normalize = (rows: unknown[]) => rows.map((row) => JSON.stringify(row)).sort();
|
||||
assert.deepEqual(normalize(treatment.rows), normalize(baseline[name].rows));
|
||||
results.push({ name, baselineMs: baseline[name].medianMs, treatmentMs: treatment.medianMs,
|
||||
speedup: baseline[name].medianMs / treatment.medianMs, equalRows: treatment.rows.length,
|
||||
baselinePlan: baseline[name].explain, treatmentPlan: treatment.explain });
|
||||
}
|
||||
console.log(JSON.stringify({ baselineRevision, postgres: (await db.$client`SELECT version()`)[0].version,
|
||||
fixture: { issues: 335, runs: 1400, wakes: 13000, actions: 377, promptBytes: Buffer.byteLength(prompt) }, results }, null, 2));
|
||||
} finally {
|
||||
try {
|
||||
await db.$client.end({ timeout: 0 });
|
||||
await database.cleanup();
|
||||
if (baselineModuleDir) await fs.rm(baselineModuleDir, { recursive: true, force: true });
|
||||
} finally {
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { featureIndex } from "./feature-map.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const defaultRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
@@ -83,7 +84,8 @@ export function checkMaps(repoRoot = defaultRoot) {
|
||||
|
||||
const productFiles = files(featureRoot);
|
||||
const agentFiles = files(agentRoot);
|
||||
const indexes = [read(path.join(featureRoot, "README.md")), read(path.join(agentRoot, "README.md"))];
|
||||
try { featureIndex(repoRoot); }
|
||||
catch (error) { errors.push(error.message); }
|
||||
let capabilities = 0;
|
||||
for (const name of new Set([...productFiles, ...agentFiles])) {
|
||||
const productFile = path.join(featureRoot, name);
|
||||
@@ -109,12 +111,6 @@ export function checkMaps(repoRoot = defaultRoot) {
|
||||
}
|
||||
if (!agent.includes(`](../feature-map/${name})`)) fail(agentFile, "missing matching product link");
|
||||
if (!product.includes(`](../agent-access-map/${name})`)) fail(productFile, "missing matching companion link");
|
||||
for (const [index, text] of indexes.entries()) {
|
||||
if (!new RegExp(`\\]\\((?:\\./)?${name.replaceAll(".", "\\.")}\\)`).test(text)) {
|
||||
fail(path.join(index === 0 ? featureRoot : agentRoot, "README.md"), `missing area: ${name}`);
|
||||
}
|
||||
}
|
||||
if (!/^Review date: \d{4}-\d{2}-\d{2}\. Source commit: `[a-f\d]{40}`\./m.test(agent)) fail(agentFile, "missing review date/source commit");
|
||||
links(productFile, product);
|
||||
links(agentFile, agent);
|
||||
capabilities += ids.length;
|
||||
|
||||
@@ -21,7 +21,7 @@ function fixture(t) {
|
||||
const agent = path.join(root, "maps/agent-access-map/example.md");
|
||||
writeFileSync(path.join(root, "maps/README.md"), "# Contract\n");
|
||||
for (const dir of ["feature-map", "agent-access-map"]) writeFileSync(path.join(root, "maps", dir, "README.md"), "[Example](example.md)\n");
|
||||
writeFileSync(product, "# Example\n\n[Agent access](../agent-access-map/example.md)\n\n## Sub-features\n\n- `create`: create a record.\n\n## How to get to it (user POV)\n");
|
||||
writeFileSync(product, '---\nnavigation: {"section":"Work","sectionOrder":10,"order":10}\n---\n# Example\n\nExample workflows.\n\n[Agent access](../agent-access-map/example.md)\n\n## Sub-features\n\n- `create`: create a record.\n\n## How to get to it (user POV)\n');
|
||||
writeFileSync(agent, `# Agent access\n\nProduct map: [Example](../feature-map/example.md).\n\nReview date: 2026-10-12. Source commit: \`${"a".repeat(40)}\`.\n\n## \`create\`\n\nReview: \`baseline\`.\n\n${record}`);
|
||||
return { root, product, agent };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const sidebarLabel = title => title.replaceAll("/", ", ");
|
||||
|
||||
export function featureIndex(root) {
|
||||
const directory = path.join(root, "maps/feature-map");
|
||||
const sections = new Map();
|
||||
for (const file of readdirSync(directory).filter(file => file.endsWith(".md") && file !== "README.md").sort()) {
|
||||
const id = file.slice(0, -3);
|
||||
const source = readFileSync(path.join(directory, file), "utf8");
|
||||
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||||
const navigationLine = frontmatter?.[1].match(/^navigation: (.+)$/m)?.[1];
|
||||
if (!navigationLine) throw new Error(`${file}: missing navigation metadata`);
|
||||
const navigation = JSON.parse(navigationLine);
|
||||
if (typeof navigation.section !== "string" || !navigation.section.trim()
|
||||
|| !Number.isFinite(navigation.sectionOrder) || !Number.isFinite(navigation.order)) {
|
||||
throw new Error(`${file}: invalid navigation metadata`);
|
||||
}
|
||||
const text = source.slice(frontmatter[0].length);
|
||||
const title = text.match(/^# (.+)$/m)?.[1];
|
||||
const description = text.split(/^# .+\r?\n/m)[1]?.trim().split(/\r?\n\s*\r?\n/)[0].replaceAll(/\r?\n/g, " ");
|
||||
if (!title || !description) throw new Error(`${file}: missing title or description`);
|
||||
const body = text.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0];
|
||||
if (!body) throw new Error(`${file}: missing sub-features`);
|
||||
const capabilities = [];
|
||||
for (const row of body.trim().split(/\r?\n/)) {
|
||||
const match = row.match(/^- `([^`]+)`:\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, key, description] = match;
|
||||
capabilities.push({ id: `${id}/${key}`, label: key.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase()), description });
|
||||
} else if (row.trim() && capabilities.length) capabilities.at(-1).description += ` ${row.trim()}`;
|
||||
}
|
||||
if (!capabilities.length || capabilities.some(capability => !/^[a-z0-9-]+\/[a-z0-9-]+$/.test(capability.id))
|
||||
|| new Set(capabilities.map(capability => capability.id)).size !== capabilities.length) {
|
||||
throw new Error(`${file}: invalid or duplicate capability IDs`);
|
||||
}
|
||||
const verification = text.split(/^### Storybook verification\s*$/m)[1]?.split(/^### /m)[0] ?? "";
|
||||
const seen = new Set();
|
||||
for (const entry of verification.split(/^#### /m).slice(1)) {
|
||||
const key = entry.match(/^`([^`]+)`/)?.[1];
|
||||
const capability = capabilities.find(capability => capability.id === `${id}/${key}`);
|
||||
if (!capability || seen.has(key)) throw new Error(`${file}: unknown or duplicate verification capability ${key}`);
|
||||
seen.add(key);
|
||||
const fields = Object.fromEntries(["Scope", "Storybook", "Production", "Sources"].map(field => [field.toLowerCase(), entry.match(new RegExp(`^${field}: (.+)$`, "m"))?.[1]]));
|
||||
if (!["ui", "cli", "browser"].includes(fields.scope) || Object.values(fields).some(value => !value?.trim())) {
|
||||
throw new Error(`${file}: incomplete verification route for ${key}`);
|
||||
}
|
||||
capability.verification = fields;
|
||||
}
|
||||
let section = sections.get(navigation.section);
|
||||
if (!section) {
|
||||
section = { title: navigation.section, order: navigation.sectionOrder, features: [] };
|
||||
sections.set(section.title, section);
|
||||
}
|
||||
if (section.order !== navigation.sectionOrder) throw new Error(`${file}: inconsistent section order for ${section.title}`);
|
||||
section.features.push({ id, title, description, capabilities, order: navigation.order });
|
||||
}
|
||||
if (!sections.size) throw new Error("Feature map has no feature families");
|
||||
const ordered = [...sections.values()].sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
|
||||
return { sections: ordered.map(({ title, features }) => ({ title, features: features.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)).map(({ order, ...feature }) => feature) })) };
|
||||
}
|
||||
|
||||
export function mapIndexes(index) {
|
||||
const features = ["# Product features", "", "Generated from the feature recipes. Edit the affected recipe, not this index.", ""];
|
||||
const access = ["# Agent access by feature", "", "Generated from the feature recipes and their access companions. Counts are documentation scope, not passing tests.", ""];
|
||||
for (const section of index.sections) {
|
||||
for (const lines of [features, access]) lines.push(`## ${section.title}`, "");
|
||||
for (const feature of section.features) {
|
||||
features.push(`- [${feature.title}](../feature-map/${feature.id}.md): ${feature.description}`);
|
||||
access.push(`- [${feature.title}](../agent-access-map/${feature.id}.md) · [Product recipe](../feature-map/${feature.id}.md) · ${feature.capabilities.length} capabilities`);
|
||||
}
|
||||
for (const lines of [features, access]) lines.push("");
|
||||
}
|
||||
return new Map([["maps/generated/feature-map.md", features.join("\n")], ["maps/generated/agent-access-map.md", access.join("\n")]]);
|
||||
}
|
||||
|
||||
export function pageInventory(root, index) {
|
||||
const references = new Map();
|
||||
for (const feature of index.sections.flatMap(section => section.features)) {
|
||||
const recipe = `maps/feature-map/${feature.id}.md`;
|
||||
for (const match of readFileSync(path.join(root, recipe), "utf8").matchAll(/\]\(([^)\n]+)\)/g)) {
|
||||
const target = path.resolve(root, path.dirname(recipe), match[1].split("#")[0]);
|
||||
if (!references.has(target)) references.set(target, new Set());
|
||||
references.get(target).add(feature.id);
|
||||
}
|
||||
}
|
||||
function pages(directory) {
|
||||
if (!existsSync(directory)) return [];
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) return pages(file);
|
||||
if (!entry.name.endsWith(".tsx") || /\.(test|spec|stories)\.tsx$/.test(entry.name)) return [];
|
||||
return [{ file: path.relative(root, file).split(path.sep).join("/"), features: [...(references.get(file) ?? [])].sort() }];
|
||||
});
|
||||
}
|
||||
return { pages: pages(path.join(root, "ui/src/pages")).sort((a, b) => a.file.localeCompare(b.file)) };
|
||||
}
|
||||
@@ -2057,6 +2057,62 @@ const validateApp = (app) => {
|
||||
);
|
||||
}
|
||||
};
|
||||
apps.push({
|
||||
"schemaVersion": 1,
|
||||
"slug": "speko",
|
||||
"name": "Speko",
|
||||
"description": "Talk with a Paperclip agent through an experimental hosted voice connection.",
|
||||
"categories": [
|
||||
"communication"
|
||||
],
|
||||
"featured": false,
|
||||
"branding": {
|
||||
"logoUrl": "/brands/apps/speko.svg"
|
||||
},
|
||||
"urlPatterns": [
|
||||
"https://speko.ai/*",
|
||||
"https://platform.speko.ai/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "voice-agent",
|
||||
"label": "Talk with an agent",
|
||||
"purpose": "channel",
|
||||
"provider": "speko",
|
||||
"transport": "voice",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use browser voice to start and continue work with a selected Paperclip agent.",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "apiKey",
|
||||
"label": "Speko API key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Speko API key",
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"key": "agentId",
|
||||
"label": "Speko agent ID",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "agent_\u2026",
|
||||
"secret": false
|
||||
}
|
||||
],
|
||||
"guidanceMd": "Create a dedicated Speko voice persona and choose its voice. Paperclip registers signed session tools and keeps the selected Paperclip agent responsible for task work. Public HTTPS callbacks are required. Review provider recording settings; Speko charges are separate from agent costs. Phone calling is withheld pending live qualification.",
|
||||
"consoleLinks": {
|
||||
"register": "https://platform.speko.ai/agents",
|
||||
"docs": "https://docs.speko.ai/guides/tool-calling"
|
||||
},
|
||||
"riskTier": "S3",
|
||||
"requiredResourceFilters": []
|
||||
}
|
||||
]
|
||||
});
|
||||
const captureFiles = definitionsOnly ? [] : fs
|
||||
.readdirSync(corpus)
|
||||
.filter((fileName) => fileName.endsWith(".md") && fileName !== "INDEX.md")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function mapFixture(t) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "paperclip-map-test-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
function write(file, content) {
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), content);
|
||||
}
|
||||
write("ui/package.json", '{"type":"module"}\n');
|
||||
symlinkSync(fileURLToPath(new URL("../ui/node_modules", import.meta.url)), path.join(root, "ui/node_modules"), "junction");
|
||||
write(".gitignore", "node_modules/\nmaps/generated/\nui/storybook/generated/\nui/storybook/.storybook/preview.tsx\n");
|
||||
write("maps/README.md", "# Maps\n");
|
||||
write("maps/feature-map/README.md", "# Feature recipes\n");
|
||||
write("maps/agent-access-map/README.md", "# Access contracts\n");
|
||||
write("ui/storybook/.storybook/preview-base.tsx", "export default { parameters: { layout: 'fullscreen' } };\n");
|
||||
function feature(id, title, capabilities = ["create"], options = {}) {
|
||||
const { section = "Work", sectionOrder = 10, order = 10 } = options;
|
||||
write(`maps/feature-map/${id}.md`, `---\nnavigation: ${JSON.stringify({ section, sectionOrder, order })}\n---\n\n# ${title}\n\n${title} workflows.\n\n[Agent access](../agent-access-map/${id}.md)\n\n## Sub-features\n\n${capabilities.map(key => `- \`${key}\`: ${key} work.`).join("\n")}\n\n## Gotchas\n`);
|
||||
write(`maps/agent-access-map/${id}.md`, `# ${title} access\n\n[Product map](../feature-map/${id}.md)\n\n${capabilities.map(key => `## \`${key}\`\n\nReview: \`baseline\`.\n\nAPI: \`present\` — POST /api/${id}.\nAgent access: \`available\` — REST.\nPolicy: same company.\nEvidence: \`source-only\` — inspect implementation.\nVerification: \`not-run\` — assert access and denial.\nGaps: remaining variants.\n`).join("\n")}`);
|
||||
}
|
||||
function story(name = "task", metadata = { capabilities: ["tasks/create"], implementation: "production", boundary: "fixtures" }, options = {}) {
|
||||
const { area = "tasks", title = `Work/Tasks/${name}`, id = `stable-${name}`, exports = "export const Empty = {};", parameters = "" } = options;
|
||||
const file = `ui/storybook/stories/${area}/${name}.stories.tsx`;
|
||||
write(file, `import type { Meta } from '@storybook/react-vite';\nconst meta = { id: ${JSON.stringify(id)}, title: ${JSON.stringify(title)}, parameters: { ${parameters} paperclip: ${JSON.stringify(metadata)} } } satisfies Meta;\nexport default meta;\n${exports}\n`);
|
||||
return file;
|
||||
}
|
||||
feature("tasks", "Tasks", ["create", "discussion"]);
|
||||
const file = story();
|
||||
return { root, write, feature, story, file };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The public packages the public-install verifier stages: `@paperclipai/server`
|
||||
// and `paperclipai` plus every public workspace package they reach through
|
||||
// `workspace:` specs, in the depth-first order the verifier packs them. The
|
||||
// npm cache warm-up reads the same closure from source manifests before
|
||||
// `pnpm build` has produced anything, so both share this one definition.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const PUBLIC_INSTALL_ROOTS = ['@paperclipai/server', 'paperclipai'];
|
||||
|
||||
// Returns `{ packages, needed }`: `packages` maps every public workspace
|
||||
// package name to `{ dir, manifest }`, and `needed` is the closure in visit
|
||||
// order.
|
||||
export function publicInstallClosure(repo, roots = PUBLIC_INSTALL_ROOTS) {
|
||||
const listing = execFileSync(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], { cwd: repo, stdio: 'pipe' })
|
||||
.toString().trim().split('\n').map(line => line.split('\t'));
|
||||
const packages = new Map(listing.map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json'), 'utf8')) }]));
|
||||
const needed = new Set();
|
||||
function visit(name) {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `Missing public workspace dependency ${name}`); needed.add(name);
|
||||
for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) {
|
||||
if (spec.startsWith('workspace:')) visit(dep);
|
||||
}
|
||||
}
|
||||
for (const root of roots) visit(root);
|
||||
return { packages, needed };
|
||||
}
|
||||
|
||||
// The registry dependencies the closure's packages declare, as one consumer
|
||||
// manifest. A name two packages declare with different specs keeps the first
|
||||
// spec: the warm-up only fills a cache, and the real install fetches whatever
|
||||
// it still lacks.
|
||||
export function publicRegistryDependencies({ packages, needed }) {
|
||||
const dependencies = {};
|
||||
const optionalDependencies = {};
|
||||
for (const name of needed) {
|
||||
const { manifest } = packages.get(name);
|
||||
for (const [section, target] of [['dependencies', dependencies], ['optionalDependencies', optionalDependencies]]) {
|
||||
for (const [dep, spec] of Object.entries(manifest[section] ?? {})) {
|
||||
if (needed.has(dep) || /^(workspace|file|link):/.test(spec) || dep in dependencies || dep in optionalDependencies) continue;
|
||||
target[dep] = spec;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { dependencies, optionalDependencies };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type StorySource = { file: string; title: string; metaId: string; ids: string[]; plays: number };
|
||||
export function generateStorybook(root?: string, options?: { write?: boolean }): {
|
||||
catalog: { version: number; entries: Array<{ file: string; stableId: string }> };
|
||||
stories: StorySource[];
|
||||
};
|
||||
export function scanStories(root?: string): StorySource[];
|
||||
+127
-80
@@ -1,18 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { featureIndex, sidebarLabel } from "./feature-map.mjs";
|
||||
import { generateFeatureArtifacts, generatedRoot, writeArtifacts } from "./storybook-feature-index.mjs";
|
||||
import { checkMaps } from "./check-maps.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const defaultRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
const storyRoot = "ui/storybook/stories";
|
||||
const catalogPath = "maps/storybook-map/catalog.json";
|
||||
const indexPath = "maps/storybook-map/INDEX.md";
|
||||
const statuses = ["current", "proposed", "historical", "mixed", "unreviewed"];
|
||||
const implementations = ["production", "prototype", "mixed", "guide"];
|
||||
const boundaries = ["fixtures", "fixture-server", "static"];
|
||||
const implementations = ["production", "prototype", "mixed", "guide", "unreviewed"];
|
||||
const boundaries = ["fixtures", "fixture-server", "static", "unreviewed"];
|
||||
const annotationFields = new Set(["capabilities", "status", "implementation", "boundary", "notes", "home", "exception", "replacement"]);
|
||||
|
||||
function files(root, dir) {
|
||||
return readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
|
||||
@@ -21,34 +22,39 @@ function files(root, dir) {
|
||||
}).sort();
|
||||
}
|
||||
|
||||
export function fingerprint(root, sources) {
|
||||
const hash = createHash("sha256");
|
||||
for (const file of [...new Set(sources)].sort()) {
|
||||
hash.update(file).update("\0").update(readFileSync(path.join(root, file))).update("\0");
|
||||
function literal(node, label) {
|
||||
if (!node) throw new Error(`${label}: expected literal metadata`);
|
||||
if (["TSAsExpression", "TSSatisfiesExpression"].includes(node.type)) return literal(node.expression, label);
|
||||
if (["StringLiteral", "BooleanLiteral", "NumericLiteral"].includes(node.type)) return node.value;
|
||||
if (node.type === "ArrayExpression") return node.elements.map(element => literal(element, label));
|
||||
if (node.type === "ObjectExpression") {
|
||||
const result = {};
|
||||
for (const property of node.properties) {
|
||||
const key = property.key?.name ?? property.key?.value;
|
||||
if (property.type !== "ObjectProperty" || property.computed || typeof key !== "string" || Object.hasOwn(result, key) || key === "__proto__") {
|
||||
throw new Error(`${label}: metadata must use unique literal properties`);
|
||||
}
|
||||
result[key] = literal(property.value, label);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return hash.digest("hex");
|
||||
throw new Error(`${label}: metadata must be literal; expressions are not executed`);
|
||||
}
|
||||
|
||||
export function capabilities(root) {
|
||||
const result = new Map();
|
||||
for (const file of files(root, "maps/feature-map").filter(file => file.endsWith(".md") && !file.endsWith("README.md"))) {
|
||||
const text = readFileSync(path.join(root, file), "utf8");
|
||||
const area = path.basename(file, ".md");
|
||||
const section = text.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0] ?? "";
|
||||
for (const match of section.matchAll(/^- `([^`]+)`:/gm)) result.set(`${area}/${match[1]}`, file);
|
||||
function annotation(csf, file) {
|
||||
const parameters = csf._metaAnnotations.parameters;
|
||||
if (parameters?.type !== "ObjectExpression") throw new Error(`${file}: declare parameters.paperclip as literal metadata`);
|
||||
const matches = parameters.properties.filter(property => (property.key?.name ?? property.key?.value) === "paperclip");
|
||||
if (matches.length !== 1) throw new Error(`${file}: expected one parameters.paperclip annotation`);
|
||||
const property = matches[0];
|
||||
if (property.type !== "ObjectProperty" || property.computed) throw new Error(`${file}: paperclip must be a literal property`);
|
||||
if (parameters.properties.slice(parameters.properties.indexOf(property) + 1).some(property => property.type === "SpreadElement" || property.computed)) {
|
||||
throw new Error(`${file}: paperclip metadata must follow parameter spreads and computed properties`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function featureNavigation(root) {
|
||||
const navigation = new Map();
|
||||
let section;
|
||||
for (const line of readFileSync(path.join(root, "maps/feature-map/README.md"), "utf8").split("\n")) {
|
||||
if (line.startsWith("### ")) section = line.slice(4);
|
||||
const match = line.match(/^\| \[([^\]]+)\]\(\.\/([^.)]+)\.md\)/);
|
||||
if (match && section) navigation.set(match[2], `${section}/${match[1]}`);
|
||||
}
|
||||
return navigation;
|
||||
const value = literal(property.value, file);
|
||||
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${file}: paperclip metadata must be an object`);
|
||||
for (const key of Object.keys(value)) if (!annotationFields.has(key)) throw new Error(`${file}: unknown paperclip field ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function scanStories(root = defaultRoot) {
|
||||
@@ -56,70 +62,81 @@ export function scanStories(root = defaultRoot) {
|
||||
const { loadCsf } = require("storybook/internal/csf-tools");
|
||||
return files(root, storyRoot).filter(file => /\.stories\.(ts|tsx|mdx)$/.test(file)).map(file => {
|
||||
const csf = loadCsf(readFileSync(path.join(root, file), "utf8"), { fileName: file, makeTitle: title => title }).parse();
|
||||
return { file, title: csf.meta.title, metaId: csf.meta.id, ids: csf.stories.map(story => story.id), plays: csf.stories.filter(story => story.__stats?.play).length };
|
||||
return { file, title: csf.meta.title, metaId: csf.meta.id, ids: csf.stories.map(story => story.id), plays: csf.stories.filter(story => story.__stats?.play).length, annotation: annotation(csf, file) };
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCatalog(root, catalog, stories) {
|
||||
export function storyHome(entry) {
|
||||
return entry.home ?? (typeof entry.capabilities?.[0] === "string" ? entry.capabilities[0].split("/")[0] : undefined) ?? (entry.implementation === "guide" ? "overview" : "foundations");
|
||||
}
|
||||
|
||||
export function catalogFromStories(stories, index) {
|
||||
const navigation = new Map(index.sections.flatMap(section => section.features.map(feature => [feature.id, `${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}`])));
|
||||
return { version: 2, entries: stories.map(story => {
|
||||
const entry = { status: "unreviewed", implementation: "unreviewed", boundary: "unreviewed", notes: "", ...story.annotation, file: story.file, stableId: story.metaId, sidebarTitle: story.title };
|
||||
const prefix = navigation.get(storyHome(entry)) ?? ({ overview: "Overview", foundations: "Foundations" })[storyHome(entry)];
|
||||
return { ...entry, purpose: story.title?.startsWith(`${prefix}/`) ? story.title.slice(prefix.length + 1) : story.title };
|
||||
}) };
|
||||
}
|
||||
|
||||
export function validateCatalog(catalog, stories, index) {
|
||||
const errors = [];
|
||||
const refs = capabilities(root);
|
||||
const navigation = featureNavigation(root);
|
||||
const refs = new Set(index.sections.flatMap(section => section.features.flatMap(feature => feature.capabilities.map(capability => capability.id))));
|
||||
const navigation = new Map(index.sections.flatMap(section => section.features.map(feature => [feature.id, `${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}`])));
|
||||
const byFile = new Map(stories.map(story => [story.file, story]));
|
||||
const seen = new Set();
|
||||
const local = file => typeof file === "string" && !path.isAbsolute(file) && !file.split("/").includes("..") && existsSync(path.join(root, file));
|
||||
if (catalog.version !== 1 || !Array.isArray(catalog.entries)) return ["Unsupported catalog version or missing entries"];
|
||||
function review(label, sources, hash) {
|
||||
if (!Array.isArray(sources) || !sources.length || sources.some(file => !local(file))) {
|
||||
errors.push(`${label}: missing/invalid review sources`);
|
||||
} else if (fingerprint(root, sources) !== hash) errors.push(`${label}: source changed; review before updating its fingerprint`);
|
||||
}
|
||||
review("Shared Storybook setup", catalog.sharedReview?.sources, catalog.sharedReview?.fingerprint);
|
||||
for (const entry of catalog.entries) {
|
||||
const label = entry.file;
|
||||
if (seen.has(label)) errors.push(`${label}: duplicate catalog entry`);
|
||||
seen.add(label);
|
||||
if (!byFile.has(label)) errors.push(`${label}: missing story file`);
|
||||
if (!statuses.includes(entry.status)) errors.push(`${label}: invalid product status`);
|
||||
if (!implementations.includes(entry.implementation)) errors.push(`${label}: invalid implementation`);
|
||||
if (!boundaries.includes(entry.boundary)) errors.push(`${label}: invalid data boundary`);
|
||||
if (!entry.purpose?.trim() || !entry.notes?.trim()) errors.push(`${label}: purpose and review notes are required`);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.reviewedAt ?? "")) errors.push(`${label}: missing review date`);
|
||||
if (!Array.isArray(entry.capabilities)) { errors.push(`${label}: missing capabilities`); continue; }
|
||||
if (typeof entry.notes !== "string") errors.push(`${label}: notes must be text`);
|
||||
if (!Array.isArray(entry.capabilities) || entry.capabilities.some(ref => typeof ref !== "string")) { errors.push(`${label}: capabilities must be an array of IDs`); continue; }
|
||||
if (entry.home !== undefined && entry.home !== "foundations") errors.push(`${label}: only shared foundations may override the capability home`);
|
||||
if (entry.exception !== undefined && (typeof entry.exception !== "string" || !entry.exception.trim())) errors.push(`${label}: exception must be nonempty text`);
|
||||
if (entry.home === "foundations" && !entry.exception) errors.push(`${label}: foundation home needs an explicit reason`);
|
||||
for (const ref of entry.capabilities) if (!refs.has(ref)) errors.push(`${label}: unknown feature capability ${ref}`);
|
||||
if (!entry.capabilities.length && !entry.exception?.trim()) errors.push(`${label}: unmapped story needs an explicit reason`);
|
||||
if (!entry.capabilities.length && !entry.exception) errors.push(`${label}: unmapped story needs an explicit reason`);
|
||||
if (entry.capabilities.length !== new Set(entry.capabilities).size) errors.push(`${label}: duplicate capability references`);
|
||||
const area = entry.capabilities[0]?.split("/")[0] ?? (entry.implementation === "guide" ? "overview" : "foundations");
|
||||
const area = storyHome(entry);
|
||||
const prefix = navigation.get(area) ?? ({ overview: "Overview", foundations: "Foundations" })[area];
|
||||
const story = byFile.get(label);
|
||||
if (!label.startsWith(`${storyRoot}/${area}/`)) errors.push(`${label}: story must live in its primary feature folder ${area}`);
|
||||
if (!prefix || !entry.sidebarTitle?.startsWith(`${prefix}/`) || story?.title !== entry.sidebarTitle) errors.push(`${label}: sidebar title must match its feature-map home`);
|
||||
if (!entry.stableId || story?.metaId !== entry.stableId) errors.push(`${label}: explicit meta ID must preserve the catalog stableId`);
|
||||
if (entry.replacement && !byFile.has(entry.replacement)) errors.push(`${label}: replacement story is missing`);
|
||||
if (!entry.sources?.includes(label)) errors.push(`${label}: review must include its story source`);
|
||||
review(label, entry.sources, entry.fingerprint);
|
||||
if (!prefix || typeof entry.sidebarTitle !== "string" || !entry.sidebarTitle.startsWith(`${prefix}/`)) errors.push(`${label}: sidebar title must match its feature-map home`);
|
||||
if (typeof entry.stableId !== "string" || !entry.stableId.trim()) errors.push(`${label}: explicit meta ID is required`);
|
||||
if (entry.replacement !== undefined && !byFile.has(entry.replacement)) errors.push(`${label}: replacement story is missing`);
|
||||
}
|
||||
for (const story of stories) if (!seen.has(story.file)) errors.push(`${story.file}: story missing from catalog`);
|
||||
const ids = stories.flatMap(story => story.ids);
|
||||
if (ids.length !== new Set(ids).size) errors.push("Duplicate story IDs");
|
||||
const metaIds = stories.map(story => story.metaId);
|
||||
const ids = [...stories.flatMap(story => story.ids), ...index.sections.flatMap(section => section.features.map(feature => `feature-map-${feature.id}--overview`))];
|
||||
if (metaIds.length !== new Set(metaIds).size || ids.length !== new Set(ids).size) errors.push("Duplicate story IDs");
|
||||
return errors;
|
||||
}
|
||||
|
||||
function cell(value) { return String(value).replaceAll("|", "\\|").replaceAll("\n", " "); }
|
||||
|
||||
export function renderIndex(catalog, stories, refs) {
|
||||
export function renderIndex(catalog, stories, index) {
|
||||
const byFile = new Map(stories.map(story => [story.file, story]));
|
||||
const groups = new Map();
|
||||
for (const entry of catalog.entries) {
|
||||
const area = entry.capabilities[0]?.split("/")[0] ?? "foundations-and-guides";
|
||||
const home = storyHome(entry);
|
||||
const area = ["foundations", "overview"].includes(home) ? "foundations-and-guides" : home;
|
||||
if (!groups.has(area)) groups.set(area, []);
|
||||
groups.get(area).push(entry);
|
||||
}
|
||||
const lines = ["# Storybook by feature", "", "<!-- Generated by node scripts/storybook-catalog.mjs --write. Edit catalog.json. -->", "",
|
||||
"[Contract and maintenance](README.md) · [Review findings](REVIEW.md)", "",
|
||||
const lines = ["# Storybook by feature", "", "Generated from story annotations and feature recipes. Do not edit this file.", "",
|
||||
"[Contract and maintenance](../storybook-map/README.md) · [Verification routes](verification.md)", "",
|
||||
`${stories.length} story files; ${stories.reduce((n, story) => n + story.ids.length, 0)} indexed stories. Counts describe inventory, not passed tests or complete feature coverage.`, "",
|
||||
"Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file.", ""];
|
||||
for (const [area, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
lines.push(`## ${area}`, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []),
|
||||
"Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Limitations are authored alongside each story. Classification is not executed evidence. Related capabilities cross-list one canonical story file.", ""];
|
||||
const homes = index.sections.flatMap(section => section.features.map(feature => ({ ...feature, section: section.title })));
|
||||
homes.push({ id: "foundations-and-guides", title: "Shared foundations and guides", section: "Foundations", description: "Shared components and contributor guidance outside the product feature index." });
|
||||
let currentSection;
|
||||
for (const feature of homes) {
|
||||
const area = feature.id;
|
||||
const entries = groups.get(area) ?? [];
|
||||
if (feature.section !== currentSection) {
|
||||
lines.push(`## ${feature.section}`, "");
|
||||
currentSection = feature.section;
|
||||
}
|
||||
lines.push(`### ${feature.title}`, "", feature.description, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []));
|
||||
if (!entries.length) lines.push("No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature.", "");
|
||||
else lines.push(
|
||||
"| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |",
|
||||
"| --- | --- | --- | --- | --- |");
|
||||
for (const entry of entries.sort((a, b) => a.file.localeCompare(b.file))) {
|
||||
@@ -129,24 +146,54 @@ export function renderIndex(catalog, stories, refs) {
|
||||
lines.push("");
|
||||
}
|
||||
lines.push("## Capability lookup", "", "References are navigation links, not proof of parity. An absent link means no cataloged story, not a missing product feature. CLI/operator capabilities may need no Storybook example.", "");
|
||||
for (const ref of refs.keys()) {
|
||||
for (const ref of index.sections.flatMap(section => section.features.flatMap(feature => feature.capabilities.map(capability => capability.id)))) {
|
||||
const entries = catalog.entries.filter(entry => entry.capabilities.includes(ref));
|
||||
lines.push(`- \`${ref}\`: ${entries.length ? entries.map(entry => `[${cell(byFile.get(entry.file).title)}](../../${entry.file}) (${entry.status})`).join(", ") : "No cataloged story."}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
export function renderVerification(catalog, index) {
|
||||
const examples = catalog.entries.filter(entry => entry.implementation !== "guide" && !["historical", "proposed"].includes(entry.status));
|
||||
const capabilities = index.sections.flatMap(section => section.features.flatMap(feature => feature.capabilities));
|
||||
const missing = capabilities.filter(capability => !examples.some(entry => entry.capabilities.includes(capability.id)));
|
||||
const lines = ["# Storybook verification routes", "", "Generated from story annotations and feature recipes. Associations are navigation, not proof of passing tests.", "",
|
||||
`${capabilities.length} capabilities; ${missing.length} without a current, mixed or unreviewed example. Historical examples, proposals and guides do not remove gaps.`, "",
|
||||
"## Capabilities without examples", ""];
|
||||
for (const capability of missing) {
|
||||
const area = capability.id.split("/")[0];
|
||||
lines.push(`- [${capability.id}](../feature-map/${area}.md): ${capability.verification?.storybook ?? "No example associated. Inspect the feature recipe to select verification."}`);
|
||||
}
|
||||
lines.push("", "## Authored verification guidance", "");
|
||||
for (const capability of capabilities.filter(capability => capability.verification)) {
|
||||
const route = capability.verification;
|
||||
lines.push(`### ${capability.id}`, "", `Scope: ${route.scope}`, "", `Storybook: ${route.storybook}`, "", `Production: ${route.production}`, "", `Sources: ${route.sources}`, "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateStorybook(root = defaultRoot, { write = true } = {}) {
|
||||
const mapErrors = checkMaps(root).errors;
|
||||
if (mapErrors.length) throw new Error(mapErrors.join("\n"));
|
||||
const index = featureIndex(root);
|
||||
const stories = scanStories(root);
|
||||
const catalog = catalogFromStories(stories, index);
|
||||
const errors = validateCatalog(catalog, stories, index);
|
||||
if (errors.length) throw new Error(errors.join("\n"));
|
||||
if (write) {
|
||||
generateFeatureArtifacts(root, index);
|
||||
writeArtifacts(root, new Map([
|
||||
[`${generatedRoot}/catalog.json`, JSON.stringify(catalog, null, 2) + "\n"],
|
||||
["maps/generated/storybook.md", renderIndex(catalog, stories, index)],
|
||||
["maps/generated/verification.md", renderVerification(catalog, index)],
|
||||
]));
|
||||
}
|
||||
return { catalog, stories, index };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.some(arg => arg !== "--write")) throw new Error("Usage: node scripts/storybook-catalog.mjs [--write]");
|
||||
const catalog = JSON.parse(readFileSync(path.join(defaultRoot, catalogPath), "utf8"));
|
||||
const stories = scanStories();
|
||||
const errors = validateCatalog(defaultRoot, catalog, stories);
|
||||
if (!errors.length) {
|
||||
const generated = renderIndex(catalog, stories, capabilities(defaultRoot));
|
||||
if (args.includes("--write")) writeFileSync(path.join(defaultRoot, indexPath), generated);
|
||||
else if (!existsSync(path.join(defaultRoot, indexPath)) || readFileSync(path.join(defaultRoot, indexPath), "utf8") !== generated) errors.push("Generated INDEX.md is stale; run with --write after reviewing catalog changes");
|
||||
}
|
||||
if (errors.length) { errors.forEach(error => console.error(error)); process.exitCode = 1; }
|
||||
else console.log(`Storybook catalog aligned: ${stories.length} files, ${stories.reduce((n, story) => n + story.ids.length, 0)} stories. Structure and review fingerprints only; no runtime proof.`);
|
||||
if (args.some(arg => !["--write", "--check"].includes(arg)) || args.length > 1) throw new Error("Usage: node scripts/storybook-catalog.mjs [--write | --check]");
|
||||
const { stories } = generateStorybook(defaultRoot, { write: !args.includes("--check") });
|
||||
console.log(`Storybook map: ${stories.length} files, ${stories.reduce((n, story) => n + story.ids.length, 0)} stories. Structure only; no runtime proof.`);
|
||||
}
|
||||
+136
-115
@@ -1,142 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { capabilities, fingerprint, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs";
|
||||
import { catalogFromStories, generateStorybook, renderVerification, scanStories, validateCatalog } from "./storybook-catalog.mjs";
|
||||
import { featureIndex } from "./feature-map.mjs";
|
||||
import { mapFixture } from "./map-test-fixture.mjs";
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "storybook-catalog-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
function write(file, text) {
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), text);
|
||||
}
|
||||
write("maps/feature-map/README.md", "# Features\n\n### Tasks and conversations\n\n| [Tasks](./tasks.md) | Tasks |\n");
|
||||
write("maps/feature-map/tasks.md", "# Tasks\n\n## Sub-features\n\n- `create`: create\n- `discussion`: discuss\n\n## How to get to it\n");
|
||||
write("ui/storybook/.storybook/preview.tsx", "// shared fixtures\n");
|
||||
const file = "ui/storybook/stories/tasks/task.stories.tsx";
|
||||
write(file, "// a story\n");
|
||||
write("ui/src/Task.tsx", "// production subject\n");
|
||||
const sharedSources = ["ui/storybook/.storybook/preview.tsx"];
|
||||
const sources = [file, "ui/src/Task.tsx"];
|
||||
const catalog = {
|
||||
version: 1, sharedReview: { sources: sharedSources, fingerprint: fingerprint(root, sharedSources) },
|
||||
entries: [{ file, stableId: "tasks-create", sidebarTitle: "Tasks and conversations/Tasks/Create", capabilities: ["tasks/create", "tasks/discussion"], purpose: "Create a task", status: "current",
|
||||
implementation: "production", boundary: "fixtures", notes: "Production subject; callbacks simulated.",
|
||||
reviewedAt: "2026-10-12", sources, fingerprint: fingerprint(root, sources) }],
|
||||
};
|
||||
const stories = [{ file, title: "Tasks and conversations/Tasks/Create", metaId: "tasks-create", ids: ["tasks-create--empty"], plays: 0 }];
|
||||
return { root, write, file, catalog, stories };
|
||||
function inspect(f) {
|
||||
const index = featureIndex(f.root);
|
||||
const stories = scanStories(f.root);
|
||||
const catalog = catalogFromStories(stories, index);
|
||||
return { catalog, stories, index, errors: validateCatalog(catalog, stories, index) };
|
||||
}
|
||||
|
||||
test("complete inventory accepts production subjects with simulated data", t => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []);
|
||||
test("source annotations assemble an inventory without a registry or review stamps", t => {
|
||||
const f = mapFixture(t);
|
||||
const result = inspect(f);
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.catalog.entries[0].purpose, "task");
|
||||
assert.equal(result.catalog.entries[0].status, "unreviewed");
|
||||
assert.equal(result.catalog.entries[0].stableId, "stable-task");
|
||||
assert.equal(Object.hasOwn(result.catalog.entries[0], "fingerprint"), false);
|
||||
assert.equal(Object.hasOwn(result.catalog.entries[0], "reviewedAt"), false);
|
||||
f.story("new");
|
||||
assert.equal(inspect(f).catalog.entries.length, 2);
|
||||
rmSync(path.join(f.root, f.file));
|
||||
assert.equal(inspect(f).catalog.entries.length, 1);
|
||||
});
|
||||
|
||||
test("adding, removing or duplicating story files requires reconciliation", t => {
|
||||
const f = fixture(t);
|
||||
assert.match(validateCatalog(f.root, { ...f.catalog, entries: [] }, f.stories).join("\n"), /missing from catalog/);
|
||||
assert.match(validateCatalog(f.root, f.catalog, []).join("\n"), /missing story file/);
|
||||
f.catalog.entries.push(f.catalog.entries[0]);
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /duplicate catalog entry/);
|
||||
test("unknown capabilities, invalid annotations and inappropriate homes fail", t => {
|
||||
const f = mapFixture(t);
|
||||
f.story("task", { capabilities: ["tasks/missing"] });
|
||||
assert.match(inspect(f).errors.join("\n"), /unknown feature capability/);
|
||||
f.story("task", { capabilities: [], status: "passed" });
|
||||
assert.match(inspect(f).errors.join("\n"), /invalid product status/);
|
||||
assert.match(inspect(f).errors.join("\n"), /explicit reason/);
|
||||
f.story("task", { capabilities: "tasks/create" });
|
||||
assert.match(inspect(f).errors.join("\n"), /array of IDs/);
|
||||
f.story("task", { capabilities: ["tasks/create"] }, { title: "Other/Task" });
|
||||
assert.match(inspect(f).errors.join("\n"), /sidebar title/);
|
||||
});
|
||||
|
||||
test("capability renames and unjustified unmapped stories fail", t => {
|
||||
const f = fixture(t);
|
||||
f.catalog.entries[0].capabilities = ["tasks/old-name"];
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /unknown feature capability/);
|
||||
f.catalog.entries[0].capabilities = [];
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /explicit reason/);
|
||||
f.catalog.entries[0].exception = "Shared foundation";
|
||||
const file = "ui/storybook/stories/foundations/task.stories.tsx";
|
||||
f.write(file, "export default {};");
|
||||
const entry = f.catalog.entries[0];
|
||||
entry.file = file;
|
||||
entry.sources = [file, "ui/src/Task.tsx"];
|
||||
entry.fingerprint = fingerprint(f.root, entry.sources);
|
||||
entry.sidebarTitle = "Foundations/Task";
|
||||
f.stories[0].file = file;
|
||||
f.stories[0].title = entry.sidebarTitle;
|
||||
assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []);
|
||||
test("metadata is parsed without executing code or accepting obsolete bookkeeping fields", t => {
|
||||
const f = mapFixture(t);
|
||||
f.write(f.file, `export default { id: 'task', title: 'Work/Tasks/Test', parameters: { paperclip: (() => { throw new Error('executed'); })() } }; export const Empty = {};`);
|
||||
assert.throws(() => scanStories(f.root), /expressions are not executed/);
|
||||
f.story("task", { capabilities: ["tasks/create"], fingerprint: "old" });
|
||||
assert.throws(() => scanStories(f.root), /unknown paperclip field fingerprint/);
|
||||
f.write(f.file, "export default { id: 'task', title: 'Work/Tasks/Test', parameters: { paperclip: { capabilities: [], capabilities: [] } } }; export const Empty = {};");
|
||||
assert.throws(() => scanStories(f.root), /unique literal properties/);
|
||||
});
|
||||
|
||||
test("feature organization rejects wrong folders, sidebar homes and unstable meta IDs", t => {
|
||||
const f = fixture(t);
|
||||
f.catalog.entries[0].capabilities = ["tasks/discussion"];
|
||||
assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []);
|
||||
f.stories[0].metaId = "renamed-task";
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /explicit meta ID/);
|
||||
f.stories[0].metaId = "tasks-create";
|
||||
f.stories[0].title = "Unrelated/Task";
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /sidebar title/);
|
||||
f.stories[0].title = f.catalog.entries[0].sidebarTitle;
|
||||
const originalFile = f.catalog.entries[0].file;
|
||||
f.catalog.entries[0].file = "ui/storybook/stories/foundations/task.stories.tsx";
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /primary feature folder tasks/);
|
||||
f.catalog.entries[0].file = originalFile;
|
||||
f.write("maps/feature-map/README.md", "### Conversations\n| [Tasks](./tasks.md) | Tasks |\n");
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /sidebar title/);
|
||||
test("parameter spreads cannot silently overwrite a source annotation", t => {
|
||||
const f = mapFixture(t);
|
||||
f.write(f.file, "export default { id: 'task', title: 'Work/Tasks/Test', parameters: { paperclip: { capabilities: [] }, ...other } }; export const Empty = {};");
|
||||
assert.throws(() => scanStories(f.root), /must follow parameter spreads/);
|
||||
f.write(f.file, "export default { id: 'task', title: 'Work/Tasks/Test', parameters: { paperclip: { capabilities: [] }, [key]: other } }; export const Empty = {};");
|
||||
assert.throws(() => scanStories(f.root), /must follow parameter spreads/);
|
||||
f.write(f.file, "export default { id: 'task', title: 'Work/Tasks/Test', parameters: { ['paperclip']: { capabilities: [] } } }; export const Empty = {};");
|
||||
assert.throws(() => scanStories(f.root), /must be a literal property/);
|
||||
});
|
||||
|
||||
test("production and shared fixture drift invalidate review fingerprints", t => {
|
||||
const f = fixture(t);
|
||||
f.write("ui/src/Task.tsx", "// changed contract\n");
|
||||
f.write("ui/storybook/.storybook/preview.tsx", "// changed shared mocks\n");
|
||||
const errors = validateCatalog(f.root, f.catalog, f.stories);
|
||||
assert.equal(errors.filter(error => error.includes("source changed")).length, 2);
|
||||
assert.equal(f.catalog.entries[0].fingerprint.length, 64);
|
||||
test("foundations preserve home and cross-capability references", t => {
|
||||
const f = mapFixture(t);
|
||||
f.story("control", { capabilities: ["tasks/create"], home: "foundations", exception: "Shared control", implementation: "production" }, { area: "foundations", title: "Foundations/Control" });
|
||||
const { catalog, errors } = inspect(f);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(catalog.entries.find(entry => entry.stableId === "stable-control").home, "foundations");
|
||||
f.story("control", { capabilities: ["tasks/create"], home: "foundations" }, { area: "foundations", title: "Foundations/Control" });
|
||||
assert.match(inspect(f).errors.join("\n"), /foundation home needs an explicit reason/);
|
||||
});
|
||||
|
||||
test("missing and escaping source references cannot certify a review", t => {
|
||||
const f = fixture(t);
|
||||
f.catalog.entries[0].sources.push("../outside.ts");
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /invalid review sources/);
|
||||
test("duplicate IDs, missing replacement references and collisions with generated guides fail", t => {
|
||||
const f = mapFixture(t);
|
||||
f.story("other", { capabilities: ["tasks/create"] }, { id: "stable-task" });
|
||||
assert.match(inspect(f).errors.join("\n"), /Duplicate story IDs/);
|
||||
f.story("other", { capabilities: ["tasks/create"] }, { id: "feature-map-tasks", exports: "export const Overview = {};" });
|
||||
assert.match(inspect(f).errors.join("\n"), /Duplicate story IDs/);
|
||||
f.story("other", { capabilities: ["tasks/create"], replacement: "missing.stories.tsx" });
|
||||
assert.match(inspect(f).errors.join("\n"), /replacement story is missing/);
|
||||
});
|
||||
|
||||
test("cross-capability lookup retains one canonical file and honest status", t => {
|
||||
const f = fixture(t);
|
||||
f.catalog.entries[0].status = "historical";
|
||||
const index = renderIndex(f.catalog, f.stories, capabilities(f.root));
|
||||
assert.match(index, /tasks\/create.*\(historical\)/);
|
||||
assert.match(index, /tasks\/discussion.*\(historical\)/);
|
||||
assert.match(index, /1 story files; 1 indexed stories/);
|
||||
assert.match(index, /historical \/ production \/ fixtures/);
|
||||
test("recipe, component and shared fixture edits need no fingerprint acknowledgements", t => {
|
||||
const f = mapFixture(t);
|
||||
const before = inspect(f).catalog;
|
||||
f.write("ui/src/Task.tsx", "export const Task = () => null;");
|
||||
f.write("ui/storybook/.storybook/preview-base.tsx", "export default { parameters: { changed: true } };");
|
||||
const file = "maps/feature-map/tasks.md";
|
||||
f.write(file, readFileSync(path.join(f.root, file), "utf8").replace("discussion work.", "Discuss revised behavior."));
|
||||
assert.deepEqual(inspect(f).catalog, before);
|
||||
assert.deepEqual(inspect(f).errors, []);
|
||||
});
|
||||
|
||||
test("duplicate story IDs cannot silently collapse distinct examples", t => {
|
||||
const f = fixture(t);
|
||||
f.stories[0].ids.push(f.stories[0].ids[0]);
|
||||
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /Duplicate story IDs/);
|
||||
test("check mode needs no generated outputs and generation leaves authored files intact", t => {
|
||||
const f = mapFixture(t);
|
||||
const before = readFileSync(path.join(f.root, f.file), "utf8");
|
||||
generateStorybook(f.root, { write: false });
|
||||
assert.equal(existsSync(path.join(f.root, "ui/storybook/generated")), false);
|
||||
generateStorybook(f.root);
|
||||
assert.ok(existsSync(path.join(f.root, "ui/storybook/generated/catalog.json")));
|
||||
assert.ok(existsSync(path.join(f.root, "maps/generated/verification.md")));
|
||||
assert.equal(readFileSync(path.join(f.root, f.file), "utf8"), before);
|
||||
const first = readFileSync(path.join(f.root, "maps/generated/storybook.md"), "utf8");
|
||||
generateStorybook(f.root);
|
||||
assert.equal(readFileSync(path.join(f.root, "maps/generated/storybook.md"), "utf8"), first);
|
||||
});
|
||||
|
||||
test("Storybook parser discovers nested files, filters helpers, preserves explicit IDs and counts play functions", t => {
|
||||
const f = fixture(t);
|
||||
f.write("ui/package.json", '{"type":"module"}');
|
||||
symlinkSync(fileURLToPath(new URL("../ui/node_modules", import.meta.url)), path.join(f.root, "ui/node_modules"), "junction");
|
||||
f.write(f.file, `
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
const meta = { title: "Tasks/Create", id: "stable-task", excludeStories: ["empty"] } satisfies Meta;
|
||||
test("gap reports combine associations with local verification scope and retain historical gaps", t => {
|
||||
const f = mapFixture(t);
|
||||
f.story("task", { capabilities: ["tasks/create"], status: "historical" });
|
||||
const file = "maps/feature-map/tasks.md";
|
||||
f.write(file, readFileSync(path.join(f.root, file), "utf8") + "\n### Storybook verification\n\n#### `create`\n\nScope: cli\nStorybook: No story required for the command.\nProduction: Run the command.\nSources: Inspect the CLI.\n");
|
||||
const { catalog, index } = inspect(f);
|
||||
const report = renderVerification(catalog, index);
|
||||
assert.match(report, /2 without a current, mixed or unreviewed example/);
|
||||
assert.match(report, /No story required for the command/);
|
||||
assert.match(report, /Production: Run the command/);
|
||||
});
|
||||
|
||||
test("the CSF parser preserves explicit IDs, export filtering and play counts", t => {
|
||||
const f = mapFixture(t);
|
||||
f.write(f.file, `import type { Meta } from '@storybook/react-vite';
|
||||
const meta = { id: 'stable-task', title: 'Work/Tasks/Create', excludeStories: ['helper'], parameters: { paperclip: { capabilities: ['tasks/create'] } } } satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const empty = { value: "fixture" };
|
||||
export const Empty: Story = {};
|
||||
export const Filled: Story = { play: async () => {} };
|
||||
`);
|
||||
f.write("ui/storybook/stories/nested/other.stories.ts", `
|
||||
export default { title: "Other/Subject", includeStories: ["Included"] };
|
||||
export const helper = {};
|
||||
export const Included = {};
|
||||
export const Empty = {};
|
||||
export const Filled = { play: async () => {} };
|
||||
`);
|
||||
f.write("ui/storybook/stories/nested/fixture.ts", "export const fixture = {};");
|
||||
const stories = scanStories(f.root);
|
||||
assert.equal(stories.length, 2);
|
||||
assert.deepEqual(stories.find(story => story.file === f.file), {
|
||||
file: f.file, title: "Tasks/Create", metaId: "stable-task", ids: ["stable-task--empty", "stable-task--filled"], plays: 1,
|
||||
const [story] = scanStories(f.root);
|
||||
assert.deepEqual(story.ids, ["stable-task--empty", "stable-task--filled"]);
|
||||
assert.equal(story.plays, 1);
|
||||
});
|
||||
|
||||
test("independent story and feature branches merge in either order with identical clean generated maps", t => {
|
||||
const f = mapFixture(t);
|
||||
f.write(".gitignore", readFileSync(path.join(f.root, ".gitignore"), "utf8") + "node_modules\n");
|
||||
const git = (...args) => execFileSync("git", ["-c", "user.name=Map test", "-c", "user.email=map@example.test", "-c", "commit.gpgsign=false", ...args], {
|
||||
cwd: f.root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" },
|
||||
});
|
||||
assert.deepEqual(stories.find(story => story.file.endsWith("other.stories.ts")), {
|
||||
file: "ui/storybook/stories/nested/other.stories.ts", title: "Other/Subject", metaId: undefined, ids: ["other-subject--included"], plays: 0,
|
||||
});
|
||||
git("init", "-b", "main");
|
||||
git("add", ".");
|
||||
git("commit", "-qm", "Initial recipes and story");
|
||||
git("checkout", "-b", "story-change");
|
||||
f.story("task", { capabilities: ["tasks/create"], notes: "Retain creation draft." }, { exports: "export const Empty = {}; export const Draft = {};" });
|
||||
git("add", "."); git("commit", "-qm", "Add task draft example");
|
||||
git("checkout", "-b", "feature-addition", "main");
|
||||
f.feature("goals", "Goals", ["hierarchy"], { order: 20 });
|
||||
f.story("goal", { capabilities: ["goals/hierarchy"] }, { area: "goals", title: "Work/Goals/Goal" });
|
||||
git("add", "."); git("commit", "-qm", "Add goal capability and example");
|
||||
const results = [];
|
||||
for (const [first, second] of [["story-change", "feature-addition"], ["feature-addition", "story-change"]]) {
|
||||
git("checkout", "-b", `merged-${first}`, "main");
|
||||
git("merge", "--no-edit", first);
|
||||
git("merge", "--no-edit", second);
|
||||
const result = generateStorybook(f.root);
|
||||
assert.equal(git("status", "--porcelain"), "");
|
||||
assert.deepEqual(result.stories.flatMap(story => story.ids).sort(), ["stable-goal--empty", "stable-task--draft", "stable-task--empty"]);
|
||||
results.push(["ui/storybook/generated/catalog.json", "ui/storybook/generated/feature-index.json", "maps/generated/storybook.md", "maps/generated/verification.md", "ui/storybook/.storybook/preview.tsx"].map(file => readFileSync(path.join(f.root, file), "utf8")));
|
||||
}
|
||||
assert.deepEqual(results[0], results[1]);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { featureIndex, mapIndexes, pageInventory, sidebarLabel } from "./feature-map.mjs";
|
||||
|
||||
export { featureIndex, sidebarLabel } from "./feature-map.mjs";
|
||||
export const generatedRoot = "ui/storybook/generated";
|
||||
export const generatedPreview = "ui/storybook/.storybook/preview.tsx";
|
||||
|
||||
export function featureOrder(index) {
|
||||
return ["Overview", ...index.sections.flatMap(section => [sidebarLabel(section.title),
|
||||
section.features.flatMap(feature => [sidebarLabel(feature.title), ["Overview", "*"]]),
|
||||
]), "Foundations"];
|
||||
}
|
||||
|
||||
export function featureArtifacts(index) {
|
||||
const outputs = new Map([
|
||||
[`${generatedRoot}/feature-index.json`, JSON.stringify(index, null, 2) + "\n"],
|
||||
[generatedPreview, [
|
||||
'import type { Preview } from "@storybook/react-vite";',
|
||||
'import base from "./preview-base";',
|
||||
"",
|
||||
"const preview: Preview = {",
|
||||
" ...base,",
|
||||
" parameters: {",
|
||||
" ...base.parameters,",
|
||||
" options: {",
|
||||
" ...base.parameters?.options,",
|
||||
` storySort: { order: ${JSON.stringify(featureOrder(index))} },`,
|
||||
" },",
|
||||
" },",
|
||||
"};",
|
||||
"export default preview;",
|
||||
"",
|
||||
].join("\n")],
|
||||
...mapIndexes(index),
|
||||
]);
|
||||
for (const section of index.sections) {
|
||||
for (const feature of section.features) {
|
||||
outputs.set(`${generatedRoot}/features/${feature.id}.stories.tsx`, [
|
||||
'import type { Meta, StoryObj } from "@storybook/react-vite";',
|
||||
'import { FeatureOverview } from "../../support/feature-map/FeatureOverview";',
|
||||
"",
|
||||
"const meta = {",
|
||||
` id: ${JSON.stringify(`feature-map-${feature.id}`)},`,
|
||||
` title: ${JSON.stringify(`${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}/Overview`)},`,
|
||||
" component: FeatureOverview,",
|
||||
` args: { area: ${JSON.stringify(feature.id)} },`,
|
||||
' tags: ["!autodocs"],',
|
||||
' parameters: { layout: "fullscreen" },',
|
||||
"} satisfies Meta<typeof FeatureOverview>;",
|
||||
"export default meta;",
|
||||
"export const Overview: StoryObj<typeof meta> = {};",
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
export function writeArtifacts(root, outputs) {
|
||||
for (const [file, content] of outputs) {
|
||||
const destination = path.join(root, file);
|
||||
if (existsSync(destination) && readFileSync(destination, "utf8") === content) continue;
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
writeFileSync(destination, content);
|
||||
}
|
||||
}
|
||||
|
||||
export function generateFeatureArtifacts(root, index = featureIndex(root)) {
|
||||
const outputs = featureArtifacts(index);
|
||||
outputs.set("maps/generated/coverage.json", JSON.stringify(pageInventory(root, index), null, 2) + "\n");
|
||||
const directory = `${generatedRoot}/features`;
|
||||
if (existsSync(path.join(root, directory))) {
|
||||
for (const name of readdirSync(path.join(root, directory))) {
|
||||
const file = `${directory}/${name}`;
|
||||
if (name.endsWith(".stories.tsx") && !outputs.has(file)) rmSync(path.join(root, file), { force: true });
|
||||
}
|
||||
}
|
||||
writeArtifacts(root, outputs);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
generateFeatureArtifacts(fileURLToPath(new URL("..", import.meta.url)));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs, { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { featureArtifacts, featureIndex, featureOrder, generateFeatureArtifacts } from "./storybook-feature-index.mjs";
|
||||
import { mapFixture } from "./map-test-fixture.mjs";
|
||||
|
||||
test("recipes own discovery, names, section order and multiline capability descriptions", t => {
|
||||
const f = mapFixture(t);
|
||||
f.feature("goals", "Goals", ["hierarchy"], { order: 20 });
|
||||
f.feature("apps", "Apps", ["connect"], { section: "Connections", sectionOrder: 20 });
|
||||
const recipe = path.join(f.root, "maps/feature-map/tasks.md");
|
||||
f.write("maps/feature-map/tasks.md", readFileSync(recipe, "utf8").replace("create work.", "Create work;\n retain its description."));
|
||||
const index = featureIndex(f.root);
|
||||
assert.deepEqual(index.sections.map(section => section.title), ["Work", "Connections"]);
|
||||
assert.deepEqual(index.sections.flatMap(section => section.features.map(feature => feature.id)), ["tasks", "goals", "apps"]);
|
||||
assert.equal(index.sections[0].features[0].capabilities[0].description, "Create work; retain its description.");
|
||||
assert.deepEqual(featureOrder(index), ["Overview", "Work", ["Tasks", ["Overview", "*"], "Goals", ["Overview", "*"]], "Connections", ["Apps", ["Overview", "*"]], "Foundations"]);
|
||||
});
|
||||
|
||||
test("equal local orders preserve independently added features using their stable filename", t => {
|
||||
const f = mapFixture(t);
|
||||
f.feature("goals", "Goals");
|
||||
f.feature("apps", "Apps");
|
||||
assert.deepEqual(featureIndex(f.root).sections[0].features.map(feature => feature.id), ["apps", "goals", "tasks"]);
|
||||
});
|
||||
|
||||
test("every discovered feature gets a stable overview, including areas without stories", t => {
|
||||
const f = mapFixture(t);
|
||||
f.feature("goals", "CLI/API and goals", ["hierarchy"]);
|
||||
const index = featureIndex(f.root);
|
||||
const output = featureArtifacts(index).get("ui/storybook/generated/features/goals.stories.tsx");
|
||||
assert.match(output, /id: "feature-map-goals"/);
|
||||
assert.match(output, /title: "Work\/CLI, API and goals\/Overview"/);
|
||||
});
|
||||
|
||||
test("fresh generation rebuilds missing outputs, removes retired overviews and never edits authored preview", t => {
|
||||
const f = mapFixture(t);
|
||||
const authored = readFileSync(path.join(f.root, "ui/storybook/.storybook/preview-base.tsx"), "utf8");
|
||||
f.feature("goals", "Goals", ["hierarchy"]);
|
||||
generateFeatureArtifacts(f.root);
|
||||
const overview = path.join(f.root, "ui/storybook/generated/features/goals.stories.tsx");
|
||||
assert.ok(existsSync(overview));
|
||||
assert.match(readFileSync(path.join(f.root, "ui/storybook/.storybook/preview.tsx"), "utf8"), /storySort: \{ order: \[/);
|
||||
rmSync(path.join(f.root, "maps/feature-map/goals.md"));
|
||||
rmSync(path.join(f.root, "maps/agent-access-map/goals.md"));
|
||||
generateFeatureArtifacts(f.root);
|
||||
assert.equal(existsSync(overview), false);
|
||||
assert.equal(readFileSync(path.join(f.root, "ui/storybook/.storybook/preview-base.tsx"), "utf8"), authored);
|
||||
assert.match(readFileSync(path.join(f.root, "maps/generated/feature-map.md"), "utf8"), /Tasks/);
|
||||
});
|
||||
|
||||
test("missing metadata, duplicate capabilities and inconsistent group order fail visibly", t => {
|
||||
const f = mapFixture(t);
|
||||
f.write("maps/feature-map/new.md", "# New\n");
|
||||
assert.throws(() => featureIndex(f.root), /missing navigation/);
|
||||
f.feature("new", "New", ["create", "create"]);
|
||||
assert.throws(() => featureIndex(f.root), /duplicate capability/);
|
||||
f.feature("new", "New", ["create"], { sectionOrder: 99 });
|
||||
assert.throws(() => featureIndex(f.root), /inconsistent section order/);
|
||||
});
|
||||
|
||||
test("overlapping generators can both retire the same overview", t => {
|
||||
const f = mapFixture(t);
|
||||
const retired = "ui/storybook/generated/features/retired.stories.tsx";
|
||||
f.write(retired, "export const Overview = {};\n");
|
||||
const directory = path.join(f.root, "ui/storybook/generated/features");
|
||||
const readDirectory = fs.readdirSync;
|
||||
let competed = false;
|
||||
// Reproduce the interleaving deterministically: the other generator removes
|
||||
// the file after this generator lists it, before this generator removes it.
|
||||
const mock = t.mock.method(fs, "readdirSync", (...args) => {
|
||||
const listing = readDirectory(...args);
|
||||
if (args[0] === directory && !competed) {
|
||||
competed = true;
|
||||
rmSync(path.join(f.root, retired));
|
||||
}
|
||||
return listing;
|
||||
});
|
||||
syncBuiltinESMExports();
|
||||
try {
|
||||
generateFeatureArtifacts(f.root);
|
||||
assert.equal(competed, true);
|
||||
assert.equal(existsSync(path.join(f.root, retired)), false);
|
||||
assert.ok(existsSync(path.join(directory, "tasks.stories.tsx")));
|
||||
} finally {
|
||||
mock.mock.restore();
|
||||
syncBuiltinESMExports();
|
||||
}
|
||||
});
|
||||
|
||||
test("verification guidance rejects orphan capability references", t => {
|
||||
const f = mapFixture(t);
|
||||
const file = "maps/feature-map/tasks.md";
|
||||
f.write(file, readFileSync(path.join(f.root, file), "utf8") + "\n### Storybook verification\n\n#### `missing`\n\nScope: cli\nStorybook: Use CLI.\nProduction: Execute.\nSources: Inspect.\n");
|
||||
assert.throws(() => featureIndex(f.root), /unknown or duplicate verification capability/);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
|
||||
import { materializePublishManifest } from './prepare-bundled-package.mjs';
|
||||
import { publicInstallClosure } from './public-install-closure.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
|
||||
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
|
||||
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
|
||||
@@ -231,18 +232,10 @@ try {
|
||||
// Provision as the unprivileged verification user, never into the host's /opt.
|
||||
// Only the positive probe sees this file at the canonical sandbox path.
|
||||
const grokReady = prefetch('grok', process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), stagedGrok]);
|
||||
const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t'));
|
||||
const packages = new Map(listing.map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json'), 'utf8')) }]));
|
||||
const needed = new Set();
|
||||
function visit(name) {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `Missing public workspace dependency ${name}`); needed.add(name);
|
||||
for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) {
|
||||
if (spec.startsWith('workspace:')) visit(dep);
|
||||
}
|
||||
}
|
||||
visit('@paperclipai/server');
|
||||
visit('paperclipai');
|
||||
// The server, the CLI and every public workspace package they reach through
|
||||
// `workspace:` specs. The npm cache warm-up reads the same closure, so both
|
||||
// take it from one module.
|
||||
const { packages, needed } = publicInstallClosure(repo);
|
||||
// release.sh also builds the standalone public packages (the sandbox-provider
|
||||
// plugins outside the pnpm workspace) before it stages anything, because it
|
||||
// publishes them. Nothing staged here reaches them: `needed` is closed under
|
||||
@@ -342,7 +335,13 @@ try {
|
||||
}
|
||||
const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 });
|
||||
const consumer = join(root, 'consumer'); mkdirSync(consumer);
|
||||
const cache = join(root, 'cache'); mkdirSync(cache);
|
||||
// CI can point this at a cache that scripts/warm-public-install-npm-cache.mjs
|
||||
// filled with the same public registry graph while `pnpm build` ran. npm
|
||||
// checks every cached tarball against the integrity its own resolution
|
||||
// records, so a warm cache changes where bytes come from, never what is
|
||||
// installed; the consumer directory and its lock still start empty.
|
||||
const cache = process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? resolve(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE) : join(root, 'cache');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
for (const tarball of tarballs) {
|
||||
const destination = join(assets, basename(tarball)); cpSync(tarball, destination); chmodSync(destination, 0o644);
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
// Fill an npm cache with the public registry graph of the packages the
|
||||
// public-install verifier stages, while `pnpm build` still runs. The verifier
|
||||
// starts after the build with an empty cache, and its consumer install then
|
||||
// downloads the whole graph (106-127s in runs 38241687458 to 38250487646,
|
||||
// 2026-10-12). The graph is fixed by source manifests, so it can be fetched
|
||||
// before any tarball exists.
|
||||
//
|
||||
// This only downloads. Scripts are disabled, nothing here is verified, and the
|
||||
// scratch consumer is removed afterwards. npm checks every cached tarball
|
||||
// against the integrity its own resolution records, so a warm cache changes
|
||||
// where bytes come from, never what the verifier installs. Any failure leaves
|
||||
// a partial cache that the verifier completes from the registry as before.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { publicInstallClosure, publicRegistryDependencies } from './public-install-closure.mjs';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const cache = process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE;
|
||||
assert.ok(cache, 'Set PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE to the cache directory the verifier will use');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const started = performance.now();
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'paperclip-public-install-warm-'));
|
||||
try {
|
||||
const { dependencies, optionalDependencies } = publicRegistryDependencies(publicInstallClosure(repo));
|
||||
writeFileSync(join(scratch, 'package.json'), JSON.stringify({ private: true, dependencies, optionalDependencies }));
|
||||
execFileSync('npm', ['install', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--no-package-lock'], {
|
||||
cwd: scratch, stdio: 'inherit', env: { ...process.env, npm_config_cache: cache, npm_config_update_notifier: 'false' },
|
||||
});
|
||||
console.log(JSON.stringify({ schema: 'paperclip.public-npm-install.cache-warm.v1', dependencyCount: Object.keys(dependencies).length + Object.keys(optionalDependencies).length, warm_ms: Math.round(performance.now() - started) }));
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user