From 1e7a93d5432e44654f718a747d95c3113b70541e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 24 Jul 2026 11:04:27 -0700 Subject: [PATCH] fix: truthful natives across the whole ruflo tree (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ak's natives heal and status only looked at the agentdb copies of better-sqlite3, while `npx ruflo memory` resolves its binding from @claude-flow/memory and @claude-flow/cli — both declare it only as an optionalDependency, which npm >=11.17's allow-scripts gate silently drops. Result: `ak status` said "natives ok" while ruflo's own memory ran on the sql.js WASM fallback, and `ak dual run` died mid-pipeline at the first shared write (native WAL vs WASM whole-image refusal). - healNatives() now also heals the ruflo memory-runtime contexts. - ensureNativeBsq3's install rung derives its spec from the target tree's own overrides/optionalDependencies (hardcoded `@^12` is EOVERRIDE-rejected in trees pinning better-sqlite3 — verified live); reference forms ($agentdb) and protocols are skipped. - The natives status row load-tests the binding as resolved from each runtime context (require + open :memory: + SELECT 1 in a child process) — no more file-existence false positives. - `ak dual run` pre-flights the #45 defect-2 crash condition (WASM-only runtime + live -wal/-shm sidecars) and refuses with "run: ak sync" instead of corrupting the store mid-run. - New: `memory-pin` status warning when CLAUDE_FLOW_DB_PATH pins a missing or out-of-project path (warn-only; sync never touches it). - npm 9-12 supported: the approve-scripts rung tolerates its absence on npm <=11.16 (runner-matrixed tests for npm-9 and npm-12 behaviors). 53 heal/natives tests total; check chain green. Fixes #45 --- src/commands/dual.mjs | 32 +++++ src/commands/status.mjs | 31 ++++- src/lib/heal.mjs | 26 +++- src/lib/natives.mjs | 88 +++++++++++++- tests/kit/dual-preflight.test.mjs | 44 +++++++ tests/kit/heal-natives.test.mjs | 130 +++++++++++++++++++- tests/kit/natives-runtime.test.mjs | 187 +++++++++++++++++++++++++++++ 7 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 tests/kit/dual-preflight.test.mjs create mode 100644 tests/kit/natives-runtime.test.mjs diff --git a/src/commands/dual.mjs b/src/commands/dual.mjs index 22edc29c..2cdcd1f9 100644 --- a/src/commands/dual.mjs +++ b/src/commands/dual.mjs @@ -9,6 +9,7 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { loadKitConfig } from '../lib/config.mjs'; import { have } from '../lib/exec.mjs'; +import { rufloRuntimeNatives } from '../lib/natives.mjs'; import { ok, warn, fail, info, dim, bold } from '../lib/output.mjs'; import { DUAL_RUN_TEMPLATES, DUAL_RUN_TEMPLATE_NAMES, policyToDualRunConfig, escalatePolicy, parseRouteSpecs, @@ -98,6 +99,23 @@ function runSwarm(configUrl, task, flags) { }); } +/** #45 defect 2: the dual orchestrator opens a NATIVE better-sqlite3 WAL on the + * shared DB, then shells `npx ruflo memory store`, which resolves the SAME global + * tree — if that tree is WASM-only, the sql.js writer refuses to whole-image-write + * over the live native WAL and the whole run dies at the first shared write. Refuse + * PRE-SPAWN when BOTH hold: the ruflo memory runtime lacks a native binding AND + * `-wal`/`-shm` sidecars sit beside the target DB. existsSync-based on purpose + * (EC-6): a false refusal costs one `ak sync`, a false pass corrupts the store. + * Pure + injectable so it's tested without a spawn or a global tree. + * @param {string} dbPath + * @param {{ runtime?: { installed: boolean, contexts: Array<{ ok: boolean }> } | null, + * existsSync?: typeof fs.existsSync }} [opts] */ +export function nativeWalConflict(dbPath, { runtime, existsSync = fs.existsSync } = {}) { + const wasmOnly = !!runtime?.installed && runtime.contexts.some((c) => !c.ok); + const sidecar = existsSync(`${dbPath}-wal`) || existsSync(`${dbPath}-shm`); + return { refuse: wasmOnly && sidecar, wasmOnly, sidecar }; +} + function printPlan(template, task, config) { console.log(bold(`dual run: ${template}`) + dim(` "${task}"`)); for (const w of config.workers) { @@ -138,6 +156,20 @@ async function doRun({ positionals, flags }) { return 1; } + // #45 defect 2 pre-flight: refuse BEFORE spawning a worker (the crash is otherwise + // mid-run, at the first shared-memory write). Sidecar check is a cheap fs stat, so + // only pay for the runtime probe (a child `node`) when a WAL is actually live. + const dbPath = process.env.CLAUDE_FLOW_DB_PATH ?? path.join(process.cwd(), '.claude-flow', 'dual-run-memory.db'); + if (fs.existsSync(`${dbPath}-wal`) || fs.existsSync(`${dbPath}-shm`)) { + const { refuse } = nativeWalConflict(dbPath, { runtime: await rufloRuntimeNatives() }); + if (refuse) { + fail(`refusing to start: ruflo's memory runtime lacks a native better-sqlite3 binding AND ${dbPath} has an active native WAL (-wal/-shm sidecars). ` + + 'The orchestrator\'s native WAL writer and the sql.js (WASM) `npx ruflo memory store` cannot share this DB — it would corrupt the store.'); + info('fix: run: ak sync (builds the native binding for ruflo\'s memory runtime), then retry'); + return 1; + } + } + // track the temp config-module dirs so we don't leak them (L3). const tmp = []; const mkConfig = (c) => { const url = writeConfigModule(c, task); tmp.push(path.dirname(fileURLToPath(url))); return url; }; diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 5e231755..4cdf1465 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import { glyph, dim, bold, warn } from '../lib/output.mjs'; import { loadRing, detectRegression } from '../lib/health-history.mjs'; import * as paths from '../lib/paths.mjs'; -import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; +import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; import { scanNpxStale } from '../lib/npx.mjs'; import { registrationStatus, codexMcpStatus, rufloCodexMcpStatus } from '../lib/mcp.mjs'; import { listDaemons, staleDaemons } from '../lib/daemons.mjs'; @@ -127,10 +127,39 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { if (n.aqe && !n.aqe.native) { rows.push(row('natives', 'fail', 'agentic-qe better-sqlite3 not native', 'sync repairs it')); } + // #45: the agentdb copies above are NOT what `npx ruflo memory` loads — probe + // the binding as resolved from ruflo's own memory runtime (@claude-flow/memory + // + /cli), or the row reads ✓ while memory store runs on the WASM fallback. + const rt = await rufloRuntimeNatives(); + if (rt.installed && rt.contexts.length) { + const wasm = rt.contexts.filter((c) => !c.ok); + if (wasm.length) { + rows.push(row('natives', 'fail', + `ruflo memory runtime on WASM fallback (${wasm.map((c) => `@claude-flow/${c.context}`).join(', ')}) — memory store/dual run degrade`, + 'sync builds the native binding')); + } else { + rows.push(row('natives', 'ok', `ruflo memory runtime native (${rt.contexts.map((c) => c.context).join(', ')})`)); + } + } } catch (e) { rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`)); } + // #45 aftermath: a CLAUDE_FLOW_DB_PATH pin aimed at a dead or foreign path makes + // every memory op target the wrong DB ("Database not initialized" with a healthy + // DB in-repo). Warn-only — the pin may be deliberate; sync never touches it. + try { + const pin = dbPathPinStatus({ + settingsLocalFile: path.join(process.cwd(), '.claude', 'settings.local.json'), + projectRoot: process.cwd(), + }); + if (pin?.warn) { + rows.push(row('memory-pin', 'warn', + `CLAUDE_FLOW_DB_PATH pins ${pin.pinned} (${pin.reason})`, + 'repoint it in .claude/settings.local.json env, or remove the pin')); + } + } catch { /* pin check is best-effort — never blocks status */ } + // npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the // statusline/hooks execute these verbatim, keeping retired defects alive) try { diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 8c15a8e0..41392e59 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -7,7 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { run } from './exec.mjs'; import { rufloRoot, aqeRoot } from './paths.mjs'; -import { agentdbLocations, bsq3IsNative, bsq3Root, aidefencePresent } from './natives.mjs'; +import { agentdbLocations, bsq3IsNative, bsq3Root, deriveBsq3Spec, rufloMemoryContexts, aidefencePresent } from './natives.mjs'; import { KIT_PKG } from './versions.mjs'; import { scanRvf, quarantine } from './rvf.mjs'; import { INSTALL_SPEC, INSTALL_ARGS, NIGHTLY_LABEL as RB_NIGHTLY_LABEL, nightlyAgentPlist as rbNightlyPlist, present as rbPresent, latestVersion as rbLatest, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs'; @@ -58,7 +58,11 @@ const failTail = (r) => export async function ensureNativeBsq3(dir, { runner = run } = {}) { let pkgRoot = bsq3Root(dir); if (!pkgRoot) { - await npmInstallInto(dir, 'better-sqlite3@^12', runner); + // Derive the spec from THIS tree's own overrides/deps: a hardcoded `@^12` is + // EOVERRIDE-rejected in a tree that pins better-sqlite3 (ruflo root pins + // 12.9.0, @claude-flow/cli pins ^12.9.0) — verified live. The declared spec + // installs clean and resolves a prebuilt. + await npmInstallInto(dir, `better-sqlite3@${deriveBsq3Spec(dir)}`, runner); if (bsq3IsNative(dir)) return { ok: true, how: 'native installed' }; pkgRoot = bsq3Root(dir); if (!pkgRoot) return { ok: false, how: 'FAILED (better-sqlite3 not resolvable)' }; @@ -72,8 +76,12 @@ export async function ensureNativeBsq3(dir, { runner = run } = {}) { return { ok: false, how: failTail(r) }; } -/** Native better-sqlite3 into every agentdb location that lacks it. */ -export async function healNatives() { +/** Native better-sqlite3 into every location the runtime resolves: the agentdb + * copies, agentic-qe, AND the ruflo memory-runtime contexts (@claude-flow/memory + * + /cli) — the copies `npx ruflo memory` actually loads. #45: healing only + * agentdb left ruflo's own memory on the WASM fallback (memory store failing) + * while status still read agentdb-native. `runner` injectable for hermetic tests. */ +export async function healNatives({ runner = run } = {}) { const details = []; for (const dir of agentdbLocations()) { // Re-check right before installing: an upgrade earlier in the same sync @@ -81,10 +89,16 @@ export async function healNatives() { // the 3.29.0 tree) between enumeration and heal. if (!fs.existsSync(dir)) continue; if (bsq3IsNative(dir)) continue; - details.push(`${dir}: ${(await ensureNativeBsq3(dir)).how}`); + details.push(`${dir}: ${(await ensureNativeBsq3(dir, { runner })).how}`); } if (fs.existsSync(aqeRoot()) && !bsq3IsNative(aqeRoot())) { - details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot())).how}`); + details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot(), { runner })).how}`); + } + // ruflo memory runtime — missing contexts are already filtered out (older trees + // may lack either package, EC-2), so this is a silent no-op on them. + for (const { context, dir } of rufloMemoryContexts()) { + if (bsq3IsNative(dir)) continue; + details.push(`@claude-flow/${context}: ${(await ensureNativeBsq3(dir, { runner })).how}`); } return { ok: !details.some((d) => d.includes('FAILED')), detail: details.join('; ') || 'already native everywhere' }; } diff --git a/src/lib/natives.mjs b/src/lib/natives.mjs index 37764f8c..8e58e3f3 100644 --- a/src/lib/natives.mjs +++ b/src/lib/natives.mjs @@ -5,7 +5,9 @@ // `security defend` needs (dropped from the 3.28 tree — ruvnet/ruflo#2670). import fs from 'node:fs'; import path from 'node:path'; -import { rufloNodeModules, aqeRoot } from './paths.mjs'; +import { rufloRoot, rufloNodeModules, aqeRoot } from './paths.mjs'; +import { run } from './exec.mjs'; +import { readJson } from './settings.mjs'; /** agentdb locations under the global ruflo tree (mirrors ruflo-patch-native). */ export function agentdbLocations() { @@ -48,6 +50,90 @@ export function nativesStatus() { return { locations, aqe }; } +// The packages ruflo's memory RUNTIME resolves better-sqlite3 from — not the +// agentdb copies above. @claude-flow/memory is the store; @claude-flow/cli is what +// `npx ruflo memory` runs. #45: these can be WASM-only while the agentdb copy is +// native, so the agentdb-only status was a false positive. Older ruflo trees may +// lack either package — filter to what exists so heal/status skip silently. +export function rufloMemoryContexts() { + const nm = rufloNodeModules(); + return [ + { context: 'memory', dir: path.join(nm, '@claude-flow', 'memory') }, + { context: 'cli', dir: path.join(nm, '@claude-flow', 'cli') }, + ].filter((c) => fs.existsSync(c.dir)); +} + +// A PLAIN semver range/version — the only override/dependency form npm install can +// take by value. Reference forms (`$agentdb`) and protocols (workspace:/file:/link:/ +// npm:/git+ssh:) are NOT installable specs, so they must be skipped during +// derivation rather than emitted (they'd make npm error). Requires a version digit +// so bare `*`/`latest`/`x` fall through to the caller's fallback. +const isPlainSemver = (v) => + typeof v === 'string' && /\d/.test(v) && !v.includes(':') && !v.trimStart().startsWith('$'); + +/** The install spec for better-sqlite3 in `dir`, derived from that tree's OWN + * declarations so an npm `overrides` pin isn't fought with EOVERRIDE (verified + * live: `install better-sqlite3@^12` under @claude-flow/cli, which pins ^12.9.0, + * is rejected; the declared spec succeeds). Precedence: overrides → + * optionalDependencies → dependencies → fallback; the first PLAIN-semver value + * wins, non-semver forms are skipped. */ +export function deriveBsq3Spec(dir, fallback = '^12') { + const pkg = readJson(path.join(dir, 'package.json'), {}) ?? {}; + for (const field of ['overrides', 'optionalDependencies', 'dependencies']) { + const v = pkg[field]?.['better-sqlite3']; + if (isPlainSemver(v)) return v; + } + return fallback; +} + +// A truthful load-test of the binding as node resolution finds it FROM `dir`: an +// ABI-mismatched or absent binding throws on `require` (exactly `ruflo doctor`'s +// "Could not locate the bindings file"), so requiring it, opening :memory:, and +// running SELECT 1 in a child process is the real WASM-vs-native answer — not a +// file-existence guess. Kept in a child process so a broken addon can't crash ak. +const RUNTIME_PROBE = + "const D=require(require.resolve('better-sqlite3',{paths:[process.argv[1]]}));" + + "const db=new D(':memory:');const r=db.prepare('SELECT 1 AS ok').get();db.close();" + + 'process.exit(r&&r.ok===1?0:3);'; + +/** Load-test better-sqlite3 as resolved from `dir`. Injectable runner keeps the + * test spawn-free. Returns {ok} or {ok:false, reason}. */ +export async function probeBsq3Runtime(dir, { runner = run } = {}) { + // Generous timeout for a cold `node` spawn on CI; a real native require returns + // well under the status budget (probes run in parallel, see rufloRuntimeNatives). + const r = await runner('node', ['-e', RUNTIME_PROBE, dir], { cwd: dir, timeout: 8000 }); + if (r.code === 0) return { ok: true }; + return { ok: false, reason: (r.stderr || `exit ${r.code}`).trim().split('\n').pop().slice(0, 160) }; +} + +/** Per-context native-binding truth for ruflo's memory runtime. {installed:false} + * when ruflo is absent (EC-1: status/pre-flight skip, never crash). Probes run in + * parallel to stay inside the status time budget. */ +export async function rufloRuntimeNatives({ runner = run } = {}) { + let installed; + try { installed = fs.existsSync(rufloRoot()); } catch { installed = false; } + if (!installed) return { installed: false, contexts: [] }; + const contexts = await Promise.all(rufloMemoryContexts().map(async ({ context, dir }) => { + const res = await probeBsq3Runtime(dir, { runner }); + return { context, dir, ok: res.ok, reason: res.reason }; + })); + return { installed: true, contexts }; +} + +/** Drift for a CLAUDE_FLOW_DB_PATH pin in .claude/settings.local.json `env`: warn + * when the pinned DB's directory is missing OR the path lies outside the project. + * Warn-only (the pin may be deliberate); `sync` never touches it. path.relative + * for containment so drive-letter/Windows paths compare correctly, not by prefix. + * Returns null when there is no pin (EC-4: absent/unparseable settings). */ +export function dbPathPinStatus({ settingsLocalFile, projectRoot }) { + const pinned = readJson(settingsLocalFile)?.env?.CLAUDE_FLOW_DB_PATH; + if (!pinned) return null; + if (!fs.existsSync(path.dirname(pinned))) return { warn: true, pinned, reason: 'directory does not exist' }; + const rel = path.relative(projectRoot, pinned); + if (rel.startsWith('..') || path.isAbsolute(rel)) return { warn: true, pinned, reason: 'outside the project root' }; + return { warn: false, pinned }; +} + export const aidefencePresent = () => fs.existsSync(path.join(rufloNodeModules(), '@claude-flow', 'aidefence', 'package.json')); diff --git a/tests/kit/dual-preflight.test.mjs b/tests/kit/dual-preflight.test.mjs new file mode 100644 index 00000000..18501e0b --- /dev/null +++ b/tests/kit/dual-preflight.test.mjs @@ -0,0 +1,44 @@ +// #45 defect 2 pre-flight: ak dual run must refuse BEFORE spawning a worker when +// the ruflo memory runtime is WASM-only AND the target DB has an active native WAL +// (`-wal`/`-shm` sidecars). Unit-tests the pure predicate with an injected +// existsSync + a supplied runtime probe — no spawn, no global tree. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nativeWalConflict } from '../../src/commands/dual.mjs'; + +const WASM_ONLY = { installed: true, contexts: [{ context: 'cli', ok: false }, { context: 'memory', ok: true }] }; +const ALL_NATIVE = { installed: true, contexts: [{ context: 'cli', ok: true }, { context: 'memory', ok: true }] }; +const NOT_INSTALLED = { installed: false, contexts: [] }; + +const sidecarPresent = (f) => f.endsWith('-wal') || f.endsWith('-shm'); +const noSidecar = () => false; + +test('refuses when the runtime is WASM-only AND a WAL sidecar is present', () => { + const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: sidecarPresent }); + assert.equal(c.refuse, true); + assert.equal(c.wasmOnly, true); + assert.equal(c.sidecar, true); +}); + +test('proceeds when the runtime is native even with a live WAL', () => { + const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: ALL_NATIVE, existsSync: sidecarPresent }); + assert.equal(c.refuse, false, 'native writer + native store is safe'); +}); + +test('proceeds when there are no sidecars even on a WASM-only runtime', () => { + const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: noSidecar }); + assert.equal(c.refuse, false, 'no active WAL → nothing to conflict with'); +}); + +test('proceeds when ruflo is not installed at all', () => { + const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: NOT_INSTALLED, existsSync: sidecarPresent }); + assert.equal(c.refuse, false); +}); + +test('a stale zero-length sidecar still counts as active (EC-6, presence not size)', () => { + // existsSync-based on purpose: false-refusal costs one `ak sync`, false-pass + // corrupts the DB. A -shm alone is enough. + const onlyShm = (f) => f.endsWith('-shm'); + const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: onlyShm }); + assert.equal(c.refuse, true); +}); diff --git a/tests/kit/heal-natives.test.mjs b/tests/kit/heal-natives.test.mjs index def8ecbb..fdd0bba0 100644 --- a/tests/kit/heal-natives.test.mjs +++ b/tests/kit/heal-natives.test.mjs @@ -13,8 +13,9 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { ensureNativeBsq3 } from '../../src/lib/heal.mjs'; +import { ensureNativeBsq3, healNatives } from '../../src/lib/heal.mjs'; import { bsq3IsNative } from '../../src/lib/natives.mjs'; +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; const BINDING = path.join('build', 'Release', 'better_sqlite3.node'); @@ -129,3 +130,130 @@ test('heal reports failure when better-sqlite3 stays unresolvable', async () => assert.match(r.how, /not resolvable/); fs.rmSync(root, { recursive: true, force: true }); }); + +// ── FR-2: the install rung derives its spec from the tree (EOVERRIDE fix) ───── + +/** A better-sqlite3 that node resolution CANNOT find, in a dir that pins an npm + * `overrides` for it — the npm-12 state where a direct `@^12` install is + * EOVERRIDE-rejected but the declared `^12.9.0` succeeds. */ +function overrideDir(pin) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-heal-override-')); + fs.writeFileSync(path.join(dir, 'package.json'), + JSON.stringify({ name: '@claude-flow/cli', overrides: { 'better-sqlite3': pin } })); + return dir; +} + +/** Runner that mimics npm's EOVERRIDE: `install better-sqlite3@^12` fails, the + * declared spec succeeds and plants a built copy. Records install specs seen. */ +function eoverrideNpm(declared) { + const specs = []; + const runner = async (cmd, args, opts) => { + if (args[0] === 'install' && String(args[1]).startsWith('better-sqlite3')) { + const spec = String(args[1]); + specs.push(spec); + if (spec === 'better-sqlite3@^12') { + return { code: 1, stdout: '', stderr: 'npm error code EOVERRIDE\nOverride for better-sqlite3@^12 conflicts with direct dependency\n' }; + } + const pkg = path.join(opts.cwd, 'node_modules', 'better-sqlite3'); + fs.mkdirSync(path.join(pkg, 'build', 'Release'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: 'better-sqlite3', version: '12.9.0' })); + fs.writeFileSync(path.join(pkg, BINDING), ''); + return { code: 0, stdout: '', stderr: '' }; + } + return { code: 0, stdout: '', stderr: '' }; + }; + return { runner, specs, declared }; +} + +test('ensureNativeBsq3 installs the tree-derived override spec, never the hardcoded ^12', async () => { + const dir = overrideDir('^12.9.0'); + const { runner, specs } = eoverrideNpm('^12.9.0'); + + const r = await ensureNativeBsq3(dir, { runner }); + + assert.equal(r.ok, true, 'heal succeeds with the derived spec'); + assert.ok(specs.includes('better-sqlite3@^12.9.0'), `derived spec used (saw ${JSON.stringify(specs)})`); + assert.ok(!specs.includes('better-sqlite3@^12'), 'never falls into the EOVERRIDE-rejected hardcoded ^12'); + assert.equal(bsq3IsNative(dir), true); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// ── FR-1: healNatives also heals the ruflo memory runtime contexts ────────── + +test('healNatives heals @claude-flow/memory + /cli with each tree\'s derived spec (AC-1)', async () => { + // Fake global tree: ruflo/node_modules/@claude-flow/{memory,cli}, each pinning + // better-sqlite3 via overrides, none resolvable — the npm-12 WASM-only state. + const g = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-heal-global-')); + const nm = path.join(g, 'ruflo', 'node_modules'); + for (const c of ['memory', 'cli']) { + const dir = path.join(nm, '@claude-flow', c); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), + JSON.stringify({ name: `@claude-flow/${c}`, overrides: { 'better-sqlite3': '^12.9.0' } })); + } + _setGlobalRootForTest(g); + const { runner, specs } = eoverrideNpm('^12.9.0'); + + const r = await healNatives({ runner }); + + assert.ok(specs.length >= 2, 'installed into both contexts'); + assert.ok(specs.every((s) => s === 'better-sqlite3@^12.9.0'), `only the derived spec (saw ${JSON.stringify(specs)})`); + assert.match(r.detail, /@claude-flow\/memory/); + assert.match(r.detail, /@claude-flow\/cli/); + assert.equal(r.ok, true); + _setGlobalRootForTest(null); + fs.rmSync(g, { recursive: true, force: true }); +}); + +test('healNatives skips a ruflo tree with no @claude-flow packages, without crashing (EC-2)', async () => { + const g = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-heal-bare-global-')); + fs.mkdirSync(path.join(g, 'ruflo', 'node_modules'), { recursive: true }); + _setGlobalRootForTest(g); + let installed = false; + const runner = async (cmd, args) => { if (args[0] === 'install') installed = true; return { code: 0 }; }; + + const r = await healNatives({ runner }); + + assert.equal(installed, false, 'nothing to heal → no install'); + assert.equal(r.ok, true); + _setGlobalRootForTest(null); + fs.rmSync(g, { recursive: true, force: true }); +}); + +// ── AC-2 / NFR-1: the ladder tolerates npm 9–12 approve-scripts behavior ───── + +test('ladder tolerates an npm-9 approve-scripts unknown-command and still succeeds', async () => { + // npm ≤11.16 has no `approve-scripts`; its failure must not abort the ladder. + const { agentdb, shared, cleanup } = makeTree(); + const calls = []; + const runner = async (cmd, args, _opts) => { + calls.push(args.join(' ')); + if (args[0] === 'run' && args[1] === 'install') return { code: 0 }; // rung 1: no build (simulated miss) + if (args[0] === 'approve-scripts') return { code: 1, stdout: '', stderr: 'Unknown command: "approve-scripts"\n' }; + if (args[0] === 'rebuild') { addBinding(shared); return { code: 0 }; } + return { code: 0 }; + }; + + const r = await ensureNativeBsq3(agentdb, { runner }); + + assert.equal(r.ok, true, 'ladder recovers at rebuild despite approve-scripts failing'); + assert.ok(calls.some((c) => c.startsWith('approve-scripts')), 'approve-scripts rung was attempted'); + assert.ok(calls.some((c) => c.startsWith('rebuild')), 'ladder proceeded to rebuild after the failure'); + cleanup(); +}); + +test('ladder succeeds on npm-12 (run install blocked, approve-scripts then rebuild builds)', async () => { + const { agentdb, shared, cleanup } = makeTree(); + const runner = async (cmd, args) => { + if (args[0] === 'run' && args[1] === 'install') return { code: 0 }; // blocked → no build + if (args[0] === 'approve-scripts') return { code: 0 }; // npm 12: command exists + if (args[0] === 'rebuild') { addBinding(shared); return { code: 0 }; } + return { code: 0 }; + }; + + const r = await ensureNativeBsq3(agentdb, { runner }); + + assert.equal(r.ok, true); + assert.match(r.how, /rebuilt/); + cleanup(); +}); diff --git a/tests/kit/natives-runtime.test.mjs b/tests/kit/natives-runtime.test.mjs new file mode 100644 index 00000000..5ed36eda --- /dev/null +++ b/tests/kit/natives-runtime.test.mjs @@ -0,0 +1,187 @@ +// #45 truthful-natives additions: the tree-derived install spec (FR-2), the +// ruflo memory-runtime load-test (FR-3), and the CLAUDE_FLOW_DB_PATH pin drift +// check (FR-5). All hermetic — a synthetic global tree + an injected runner, no +// npm, no network, no real child process. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + deriveBsq3Spec, rufloMemoryContexts, rufloRuntimeNatives, dbPathPinStatus, +} from '../../src/lib/natives.mjs'; +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; + +const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); +const rm = (d) => fs.rmSync(d, { recursive: true, force: true }); + +function writePkg(dir, pkg) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); +} + +/** ruflo/node_modules/@claude-flow/{memory,cli} under a fake global root. */ +function fakeGlobalTree({ contexts = ['memory', 'cli'], pkg = {} } = {}) { + const g = tmp('ak-rt-global-'); + const nm = path.join(g, 'ruflo', 'node_modules'); + fs.mkdirSync(nm, { recursive: true }); + for (const c of contexts) { + writePkg(path.join(nm, '@claude-flow', c), { name: `@claude-flow/${c}`, ...pkg }); + } + _setGlobalRootForTest(g); + return { g, nm, cleanup: () => { _setGlobalRootForTest(null); rm(g); } }; +} + +// ── FR-2: deriveBsq3Spec ──────────────────────────────────────────────────── + +test('deriveBsq3Spec honors an override pin exactly (12.9.0-style)', () => { + const dir = tmp('ak-derive-'); + writePkg(dir, { overrides: { 'better-sqlite3': '12.9.0' } }); + assert.equal(deriveBsq3Spec(dir), '12.9.0'); + rm(dir); +}); + +test('deriveBsq3Spec prefers overrides over optionalDependencies (EOVERRIDE avoidance)', () => { + const dir = tmp('ak-derive-'); + writePkg(dir, { + overrides: { 'better-sqlite3': '^12.9.0' }, + optionalDependencies: { 'better-sqlite3': '^12.0.0' }, + }); + assert.equal(deriveBsq3Spec(dir), '^12.9.0'); + rm(dir); +}); + +test('deriveBsq3Spec falls back through optionalDependencies when no override', () => { + const dir = tmp('ak-derive-'); + writePkg(dir, { optionalDependencies: { 'better-sqlite3': '^12.9.0' } }); + assert.equal(deriveBsq3Spec(dir), '^12.9.0'); + rm(dir); +}); + +test('deriveBsq3Spec skips a $ref override and a workspace protocol, falling back (EC-3)', () => { + const refDir = tmp('ak-derive-'); + writePkg(refDir, { + overrides: { 'better-sqlite3': '$agentdb' }, + dependencies: { 'better-sqlite3': 'workspace:*' }, + }); + assert.equal(deriveBsq3Spec(refDir), '^12', 'non-semver forms are skipped, fallback used'); + rm(refDir); + + const protoDir = tmp('ak-derive-'); + writePkg(protoDir, { optionalDependencies: { 'better-sqlite3': 'npm:better-sqlite3-alt@^12' } }); + assert.equal(deriveBsq3Spec(protoDir), '^12', 'npm: alias skipped'); + rm(protoDir); +}); + +test('deriveBsq3Spec falls back to ^12 when better-sqlite3 is not declared at all', () => { + const dir = tmp('ak-derive-'); + writePkg(dir, { name: 'x' }); + assert.equal(deriveBsq3Spec(dir), '^12'); + rm(dir); +}); + +// ── FR-3: rufloMemoryContexts + rufloRuntimeNatives ───────────────────────── + +test('rufloMemoryContexts enumerates only the @claude-flow packages that exist (EC-2)', () => { + const { cleanup } = fakeGlobalTree({ contexts: ['memory'] }); // older tree: no cli + const ctxs = rufloMemoryContexts(); + assert.deepEqual(ctxs.map((c) => c.context), ['memory']); + cleanup(); +}); + +test('rufloRuntimeNatives reports per-context native vs WASM via the injected child probe', async () => { + const { cleanup } = fakeGlobalTree(); + // Runner stands in for `node -e `: memory loads native (code 0), + // cli fails to load the binding (the #45 WASM-only state) → non-zero + reason. + const runner = async (cmd, args, opts) => { + assert.equal(cmd, 'node'); + if (opts.cwd.endsWith(path.join('@claude-flow', 'memory'))) return { code: 0, stdout: '', stderr: '' }; + return { code: 1, stdout: '', stderr: 'Error: Could not locate the bindings file\n' }; + }; + const rt = await rufloRuntimeNatives({ runner }); + assert.equal(rt.installed, true); + const memory = rt.contexts.find((c) => c.context === 'memory'); + const cli = rt.contexts.find((c) => c.context === 'cli'); + assert.equal(memory.ok, true); + assert.equal(cli.ok, false); + assert.match(cli.reason, /bindings file/); + cleanup(); +}); + +test('rufloRuntimeNatives reports all-ok when every context loads native', async () => { + const { cleanup } = fakeGlobalTree(); + const runner = async () => ({ code: 0, stdout: '', stderr: '' }); + const rt = await rufloRuntimeNatives({ runner }); + assert.equal(rt.installed, true); + assert.ok(rt.contexts.length >= 1); + assert.ok(rt.contexts.every((c) => c.ok), 'no false failure when all native'); + cleanup(); +}); + +test('rufloRuntimeNatives reports not-installed and never spawns when ruflo is absent (EC-1)', async () => { + const g = tmp('ak-rt-empty-'); + _setGlobalRootForTest(g); // no ruflo/ subtree + let spawned = false; + const runner = async () => { spawned = true; return { code: 0 }; }; + const rt = await rufloRuntimeNatives({ runner }); + assert.equal(rt.installed, false); + assert.deepEqual(rt.contexts, []); + assert.equal(spawned, false, 'no child process when there is nothing to probe'); + _setGlobalRootForTest(null); rm(g); +}); + +// ── FR-5: dbPathPinStatus ─────────────────────────────────────────────────── + +function settingsWith(envObj) { + const root = tmp('ak-pin-proj-'); + fs.mkdirSync(path.join(root, '.claude'), { recursive: true }); + const file = path.join(root, '.claude', 'settings.local.json'); + if (envObj !== undefined) fs.writeFileSync(file, JSON.stringify({ env: envObj })); + return { root, file }; +} + +test('dbPathPinStatus warns when the pinned directory does not exist', () => { + const missingDir = path.join(os.tmpdir(), 'ak-nope-' + Math.random().toString(36).slice(2)); + const { root, file } = settingsWith({ CLAUDE_FLOW_DB_PATH: path.join(missingDir, 'memory.db') }); + const s = dbPathPinStatus({ settingsLocalFile: file, projectRoot: root }); + assert.equal(s.warn, true); + assert.match(s.reason, /does not exist/); + rm(root); +}); + +test('dbPathPinStatus warns when the pin is outside the project (path.relative, not string-prefix)', () => { + const outside = tmp('ak-pin-outside-'); // a real, existing dir OUTSIDE the project + const { root, file } = settingsWith({ CLAUDE_FLOW_DB_PATH: path.join(outside, 'memory.db') }); + const s = dbPathPinStatus({ settingsLocalFile: file, projectRoot: root }); + assert.equal(s.warn, true); + assert.match(s.reason, /outside/); + assert.match(s.pinned, /memory\.db$/); + rm(root); rm(outside); +}); + +test('dbPathPinStatus is quiet for a valid in-project pin', () => { + const { root, file } = settingsWith({ CLAUDE_FLOW_DB_PATH: 'PLACEHOLDER' }); + const dbDir = path.join(root, '.swarm'); + fs.mkdirSync(dbDir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ env: { CLAUDE_FLOW_DB_PATH: path.join(dbDir, 'memory.db') } })); + const s = dbPathPinStatus({ settingsLocalFile: file, projectRoot: root }); + assert.equal(s.warn, false); + rm(root); +}); + +test('dbPathPinStatus is null when no pin is set', () => { + const { root, file } = settingsWith({ SOMETHING_ELSE: '1' }); + assert.equal(dbPathPinStatus({ settingsLocalFile: file, projectRoot: root }), null); + rm(root); +}); + +test('dbPathPinStatus is null when settings.local is absent or unparseable (EC-4)', () => { + const absent = tmp('ak-pin-absent-'); + assert.equal(dbPathPinStatus({ + settingsLocalFile: path.join(absent, 'nope.json'), projectRoot: absent, + }), null); + const badFile = path.join(absent, 'bad.json'); + fs.writeFileSync(badFile, '{ not json'); + assert.equal(dbPathPinStatus({ settingsLocalFile: badFile, projectRoot: absent }), null); + rm(absent); +});