Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,17 @@ Examples:
ak sync --dry-run preview the plan
ak sync --no-upgrade re-heal without touching versions`;

export async function run({ flags, pkgRoot }) {
export async function run({ flags, pkgRoot, fetchLatest }) {
const cwd = process.cwd();
// #134: draw the plan from CURRENT drift, not the TTL cache — a cache
// stamped before an upstream release claims "all current" and the upgrade
// never reaches the plan (the old force at apply time sat behind the very
// versions gate it needed to open). Dry-runs skip the refresh: it writes
// kit.json, and --dry-run is pinned to touch nothing — so a dry-run
// preview may be cache-stale by up to one TTL window.
if (!flags['dry-run'] && !flags['no-upgrade']) {
await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) });
}
const rows = await collect({ pkgRoot, cwd });
const plan = rows.filter((r) => r.fix)
.filter((r) => !(flags['no-upgrade'] && ['versions', 'self', 'ruvnet-brain', 'ruvector'].includes(r.subsystem)));
Expand Down Expand Up @@ -79,7 +88,9 @@ export async function run({ flags, pkgRoot }) {

if (subsystems.has('versions') && !flags['no-upgrade']) {
report('daemons', await heal.stopAllDaemons());
for (const d of await driftReport({ force: true })) {
// No force here: the pre-plan refresh above already ran for every
// non-dry-run, non-no-upgrade sync, so this read hits that fresh cache.
for (const d of await driftReport()) {
if (d.outdated || !d.installed) await step(`upgrade ${d.pkg}`, () => heal.upgradePackage(d.pkg));
}
}
Expand Down
13 changes: 8 additions & 5 deletions src/lib/heal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,20 @@ export async function healAidefence() {
}

/** Optional native sublinear solver for agentic-qe (best-effort). */
export async function healAqeSolver({ runner = run } = {}) {
export async function healAqeSolver() {
if (!fs.existsSync(aqeRoot())) {
return { ok: true, status: 'skipped', usable: false, detail: 'agentic-qe not installed' };
}
const probe = path.join(aqeRoot(), 'node_modules', '@ruvector', 'solver-node', 'package.json');
if (fs.existsSync(probe)) return { ok: true, status: 'ok', usable: true, detail: 'already present' };
const r = await npmInstallInto(aqeRoot(), '@ruvector/solver-node', runner);
if (r.code === 0) return { ok: true, status: 'ok', usable: true, detail: 'installed' };
// The native accelerator was never published to npm, and upstream resolved
// its own half by documenting the TypeScript solver as the implementation
// (agentic-qe#617 → #620, shipped in aqe 3.13.10). Attempting the install
// would only manufacture a 404 warning for a by-design state (#135). The
// probe above still detects a native that arrives by any other route.
return {
ok: true, status: 'degraded', usable: true,
detail: `native solver unavailable; TypeScript fallback active (<50K nodes) (${failTail(r)})`,
ok: true, status: 'ok', usable: true,
detail: 'native solver unpublished upstream (agentic-qe#617) — TypeScript fallback is the implementation (<50K nodes)',
};
}

Expand Down
23 changes: 17 additions & 6 deletions src/lib/versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ export function cmpVersions(a, b) {
const newer = (a, b) => cmpVersions(a, b) > 0;

/** Drift report for the managed packages. Network hit at most once per TTL
* window (cached in kit.json); force=true bypasses the cache. */
export async function driftReport({ force = false } = {}) {
* window (cached in kit.json); force=true bypasses the cache. A failed probe
* falls back to the cached value per package, and a run where EVERY probe
* failed neither overwrites `seen` nor stamps `last` — clobbering good data
* with nulls would suppress upgrade detection for a whole TTL window (#134). */
export async function driftReport({ force = false, fetchLatest = latestVersion } = {}) {
const cfg = loadKitConfig();
const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000;
const fresh = !force && cfg.versionCheck?.last && Date.now() - cfg.versionCheck.last < ttlMs;
Expand All @@ -67,12 +70,20 @@ export async function driftReport({ force = false } = {}) {
const HOST_PKGS = ['@anthropic-ai/claude-code', '@openai/codex', 'opencode-ai'];
const pkgs = ['ruflo', 'agentic-qe', ...HOST_PKGS.filter((p) => installedVersion(p))];
const report = [];
let latest = cfg.versionCheck?.seen ?? {};
const cached = cfg.versionCheck?.seen ?? {};
let latest = cached;
if (!fresh) {
latest = {};
for (const p of pkgs) latest[p] = await latestVersion(p);
cfg.versionCheck = { ...cfg.versionCheck, last: Date.now(), seen: latest };
try { saveKitConfig(cfg); } catch { /* read-only envs: nudge just re-fetches */ }
let succeeded = 0;
for (const p of pkgs) {
const v = await fetchLatest(p);
if (v) succeeded += 1;
latest[p] = v ?? cached[p] ?? null;
}
if (succeeded > 0) {
cfg.versionCheck = { ...cfg.versionCheck, last: Date.now(), seen: latest };
try { saveKitConfig(cfg); } catch { /* read-only envs: nudge just re-fetches */ }
}
}
for (const p of pkgs) {
const installed = installedVersion(p);
Expand Down
93 changes: 93 additions & 0 deletions tests/kit/drift-freshness.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// #134 — sync must not let the version-drift TTL cache hide an available
// upgrade, and a failed forced fetch must never clobber good cached data.
// #135's solver contract lives in heal-natives.test.mjs; this file owns the
// drift-report resilience contract and the sync plan-freshness contract.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
sandboxHome, assertSandboxed, captureLog, rmrf,
sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot,
} from './helpers/home-sandbox.mjs';

const HOME = sandboxHome('ak-drift-fresh');
const paths = await import('../../src/lib/paths.mjs');
const { driftReport } = await import('../../src/lib/versions.mjs');
const sync = await import('../../src/commands/sync.mjs');
const { loadKitConfig } = await import('../../src/lib/config.mjs');
assertSandboxed(paths, HOME);

const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const PROJECT = sandboxProject('ak-drift-fresh');
const FLAGS = (over = {}) => ({ 'dry-run': false, 'no-upgrade': false, json: false, ...over });

/** kit.json with an explicit versionCheck state; global root has ruflo+aqe fixtures. */
function seedHome({ last, seen, ruflo = '9.9.9' }) {
rmrf(paths.claudeDir(), paths.configDir());
fs.mkdirSync(paths.claudeDir(), { recursive: true });
fs.writeFileSync(paths.claudeMdPath(), '# machine notes\n');
const cfg = offlineKitConfig();
cfg.versionCheck = { ttlHours: 24, last, seen, self: cfg.versionCheck.self };
writeKitConfig(HOME, cfg);
paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo, 'agentic-qe': '9.9.9' }));
}

const inSandboxProject = async (fn) => {
const cwd = process.cwd();
process.chdir(PROJECT);
try { return await fn(); } finally { process.chdir(cwd); }
};

test('a failed forced fetch falls back to the cached seen values instead of nulling them', async () => {
seedHome({ last: 1, seen: { ruflo: '9.9.10', 'agentic-qe': '9.9.9' } }); // stale cache, good data
const report = await driftReport({ force: true, fetchLatest: async () => null }); // npm down

const ruflo = report.find((r) => r.pkg === 'ruflo');
assert.equal(ruflo.latest, '9.9.10', 'cached seen survives a failed fetch');
assert.equal(ruflo.outdated, true, '9.9.10 > installed 9.9.9 still detected');
});

test('a fully-failed forced fetch neither clobbers seen nor stamps last (TTL retries promptly)', async () => {
seedHome({ last: 1, seen: { ruflo: '9.9.10', 'agentic-qe': '9.9.9' } });
await driftReport({ force: true, fetchLatest: async () => null });

const after = loadKitConfig().versionCheck;
assert.equal(after.seen.ruflo, '9.9.10', 'seen preserved on total failure');
assert.equal(after.last, 1, 'last not stamped — the next call must retry, not trust a failed probe');
});

test('a successful forced fetch updates seen, stamps last, and reports drift', async () => {
seedHome({ last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' } }); // FRESH but wrong
const report = await driftReport({ force: true, fetchLatest: async (pkg) => (pkg === 'ruflo' ? '9.9.11' : '9.9.9') });

assert.equal(report.find((r) => r.pkg === 'ruflo').outdated, true);
const after = loadKitConfig().versionCheck;
assert.equal(after.seen.ruflo, '9.9.11');
assert.ok(after.last > 1, 'last stamped on success');
});

test('a STALE cache with newer seen data reaches the sync plan even when npm is unreachable', async () => {
// The collect() path: last=0 forces a refetch; offline that refetch fails.
// Resilient fallback must keep 9.9.10 visible so the plan includes the upgrade.
seedHome({ last: 0, seen: { ruflo: '9.9.10', 'agentic-qe': '9.9.9' } });
const { result, out } = await inSandboxProject(() =>
captureLog(() => sync.run({ flags: FLAGS({ 'dry-run': true }), pkgRoot: PKG_ROOT })));

assert.equal(result, 0);
assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.10 available/,
'the plan must surface the upgrade the cache already knows about');
});

test('sync (non-dry) force-refreshes drift BEFORE building the plan, so a fresh-but-wrong cache cannot hide an upgrade', async () => {
seedHome({ last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' } }); // fresh cache: "all current"
const { out } = await inSandboxProject(() =>
captureLog(() => sync.run({
flags: FLAGS(), pkgRoot: PKG_ROOT,
fetchLatest: async (pkg) => (pkg === 'ruflo' ? '9.9.12' : '9.9.9'), // npm knows better
})));

assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.12 available/,
'plan must be built from a forced refresh, not the stale-fresh cache');
});
32 changes: 27 additions & 5 deletions tests/kit/heal-natives.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -340,17 +340,39 @@ test('brain installer stamps only a zero-exit installation', async () => {
assert.equal(stamped, '4.0.12');
});

test('AQE native solver failure is an explicit degraded fallback', async () => {
test('AQE solver: the unpublished native is never install-attempted and the TS fallback is reported as the implementation (#135)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-solver-'));
fs.mkdirSync(path.join(root, 'agentic-qe'), { recursive: true });
_setGlobalRootForTest(root);
try {
// upstream declared the package not-installable (agentic-qe#617/#620) —
// any npm invocation here is a regression to the pre-#620 behavior.
const r = await healAqeSolver({
runner: async () => ({ code: 1, stdout: '', stderr: 'native package unavailable\n' }),
runner: async () => { throw new Error('must not shell out for @ruvector/solver-node'); },
});
assert.equal(r.ok, true, 'the TypeScript fallback remains usable');
assert.equal(r.status, 'degraded');
assert.equal(r.usable, true);
assert.equal(r.ok, true);
assert.equal(r.status, 'ok', 'expected state, not a warning');
assert.equal(r.usable, true, 'the TypeScript fallback is the implementation');
assert.match(r.detail, /unpublished upstream/);
assert.doesNotMatch(r.detail, /FAILED|npm error/, 'no error tail for a by-design state');
} finally {
_setGlobalRootForTest(null);
fs.rmSync(root, { recursive: true, force: true });
}
});

test('AQE solver: a native already present on disk is still detected and reported', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-solver-present-'));
const probe = path.join(root, 'agentic-qe', 'node_modules', '@ruvector', 'solver-node');
fs.mkdirSync(probe, { recursive: true });
fs.writeFileSync(path.join(probe, 'package.json'), '{"name":"@ruvector/solver-node"}');
_setGlobalRootForTest(root);
try {
const r = await healAqeSolver({
runner: async () => { throw new Error('must not shell out'); },
});
assert.equal(r.status, 'ok');
assert.equal(r.detail, 'already present');
} finally {
_setGlobalRootForTest(null);
fs.rmSync(root, { recursive: true, force: true });
Expand Down
Loading