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
1 change: 1 addition & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ ak sync # apply it
| Undo is unavailable or refuses current state | The receipt is irreversible, its provider/version is missing, or the target no longer matches the recorded postimage | Preserve the current state and inspect the receipt. Undo is deliberately unavailable when it could overwrite later changes; use the provider's documented manual workflow if one exists. |
| An owned skill remains report-only | Catalog identity is weaker than removal authority; the tree lacks a complete current `agentic-kit.skill-tree-ownership/v1` receipt or contains drift, symlinks, special files, or a plugin-cache path | Preserve it. Only a complete recursive manifest, exact allowed root/current owner, and exact current shape/digest can authorize archive. Never promote an issue #198 entrypoint digest into tree ownership. |
| Maintenance reports `partial-recovery-required` and blocks new changes | A provider effect may have happened, but the durable receipt cannot yet prove wholly preimage or wholly verified postimage | Run `ak maintain recover --receipt RECEIPT_ID --yes`. Recovery only inspects and reconciles; it never retries or rolls back. If state is mixed/drifted, a provider is missing, or refresh fails, repair that evidence problem and retry recovery. |
| `ak sync` misses a newly published kit release | Older versions could plan from the kit's separate 24-hour self-update cache | Normal sync now refreshes the kit's own release channels before planning. Prerelease installs check `latest` and `next`; stable installs check `latest` only. `--dry-run` and `--no-upgrade` skip this forced refresh. Failed lookups retain eligible cached evidence without renewing its timestamp. |
| `ak sync` launched from a checkout/local dependency created a global `ak` | The self-update step deliberately installs the resolved replacement globally and runs last | use `ak sync --no-upgrade` when the checkout or lockfile must remain authoritative |
| Different users or Node versions see different global stacks | npm `-g` means the active prefix, which can be per-user and per-Node-version | standardize the Node manager/prefix per user; do not repair this with `sudo ak setup` |
| `status` shows a deja-vu schema or capability warning | The CLI is older than 0.19.0, doctor JSON is missing/malformed/newer than schema 2, or an explicit enabled-host target is absent | update the owned installation with `ak sync`; update an external installation with its owner. Agentic Kit fails closed instead of guessing; see the [deja-vu runbook](DEJA-VU.md) |
Expand Down
9 changes: 6 additions & 3 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,13 @@

/** Refresh every network-backed fact that can open an upgrade gate. Kept out
* of run() so adding one release boundary does not grow the command's already
* broad orchestration complexity. Sequential: both probes persist kit.json. */
async function refreshPlanDrift(flags, fetchLatest) {
* broad orchestration complexity. Sequential: these probes persist kit.json. */
async function refreshPlanDrift(flags, fetchLatest, pkgRoot) {
if (flags['dry-run'] || flags['no-upgrade']) return;
await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) });
// Self-update has its own TTL cache; refresh it before the collector decides
// whether a self action exists. An apply-time refresh cannot open that gate.
await selfDrift({ pkgRoot, force: true, ...(fetchLatest ? { fetchLatest } : {}) });
// Brain releases have a second executability fact beyond the tag: the
// required ruvnet-brain.zip asset. A tag-only release is not actionable.
if (loadKitConfig().ruvnetBrain) await ruvnetBrainDrift({ force: true });
Expand Down Expand Up @@ -482,7 +485,7 @@
},
];

export async function run({

Check warning on line 488 in src/commands/sync.mjs

View workflow job for this annotation

GitHub Actions / quality (typecheck, lint, build, audit)

Async function 'run' has a complexity of 30. Maximum allowed is 25
flags,
pkgRoot,
fetchLatest,
Expand All @@ -500,7 +503,7 @@
// 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.
await refreshPlanDrift(flags, fetchLatest);
await refreshPlanDrift(flags, fetchLatest, pkgRoot);
const rows = await collectFn({ pkgRoot, cwd, dejaVuAdapter, dejaVuPlanOptions });
const plan = rows.filter((r) => r.fix)
// Model lifecycle actions are explicit advisory commands. `ak status` must
Expand Down
42 changes: 32 additions & 10 deletions src/lib/versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,29 +120,51 @@ export function releaseObservationLabel({ latestSource, latestObservedAt }) {

export const KIT_PKG = '@pacphi/agentic-kit';

/** Retain cached evidence only for a channel whose lookup failed. Do not
* renew the TTL unless the winning candidate was actually observed: a fresh
* latest response cannot make an older next observation fresh. */
async function fetchSelfCandidate(tags, cachedBest, fetchLatest) {
let best = null;
let observed = false;
for (const tag of tags) {
const version = await fetchLatest(KIT_PKG, tag);
const live = isValidSemver(version);
const candidate = live ? { version, tag } : cachedBest?.tag === tag ? cachedBest : null;
if (candidate && (!best || newer(candidate.version, best.version))) {
best = candidate;
observed = live;
}
}
return { best, observed };
}

/** Drift for the kit itself. Installed = the running copy's package.json
* (pkgRoot). Prerelease installs also consult the `next` dist-tag —
* prereleases publish there, so `latest` alone would never see them; the
* higher of latest/next wins. Cached in kit.json alongside versionCheck.
* @param {{ pkgRoot?: string, force?: boolean }} [opts] */
export async function selfDrift({ pkgRoot, force = false } = {}) {
* Failed lookups preserve eligible cached evidence without renewing its TTL.
* @param {{ pkgRoot?: string, force?: boolean, fetchLatest?: typeof latestVersion }} [opts] */
export async function selfDrift({ pkgRoot, force = false, fetchLatest = latestVersion } = {}) {
let installed = null;
try {
installed = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')).version;
} catch { /* unreadable pkgRoot: report as not installed */ }
const cfg = loadKitConfig();
const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000;
const cached = cfg.versionCheck?.self;
const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs;
let best = fresh ? cached.best ?? null : null;
const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest'];
const cachedBest = cached?.best && tags.includes(cached.best.tag) && isValidSemver(cached.best.version)
? cached.best : null;
const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs
&& (!cached.best || cachedBest);
let best = fresh ? cachedBest : null;
if (!fresh) {
const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest'];
for (const tag of tags) {
const v = await latestVersion(KIT_PKG, tag);
if (v && (!best || newer(v, best.version))) best = { version: v, tag };
const candidate = await fetchSelfCandidate(tags, cachedBest, fetchLatest);
best = candidate.best;
if (candidate.observed) {
cfg.versionCheck = { ...cfg.versionCheck, self: { last: Date.now(), best } };
try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ }
}
cfg.versionCheck = { ...cfg.versionCheck, self: { last: Date.now(), best } };
try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ }
}
return {
pkg: KIT_PKG,
Expand Down
4 changes: 2 additions & 2 deletions tests/kit/drift-freshness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {

const HOME = sandboxHome('ak-drift-fresh');
const paths = await import('../../src/lib/paths.mjs');
const { driftReport } = await import('../../src/lib/versions.mjs');
const { driftReport, KIT_PKG } = 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);
Expand Down Expand Up @@ -99,7 +99,7 @@ test('sync (non-dry) force-refreshes drift BEFORE building the plan, so a fresh-
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
fetchLatest: async (pkg) => pkg === KIT_PKG ? '0.0.0' : pkg === 'ruflo' ? '9.9.12' : '9.9.9', // isolate the Ruflo upgrade
})));

assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.12 available/,
Expand Down
118 changes: 118 additions & 0 deletions tests/kit/sync-self-freshness.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Exercise the real self-status collector and sync planner against isolated
// cache/package files; replace only registry I/O and the package install step.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { sandboxHome, assertSandboxed, writeKitConfig, offlineKitConfig, captureLog } from './helpers/home-sandbox.mjs';

const home = sandboxHome('ak-sync-self');
const paths = await import('../../src/lib/paths.mjs');
const { loadKitConfig } = await import('../../src/lib/config.mjs');
const sync = await import('../../src/commands/sync.mjs');
const selfSection = (await import('../../src/commands/status/sections/self.mjs')).default;
const { selfDrift, KIT_PKG } = await import('../../src/lib/versions.mjs');
assertSandboxed(paths, home);
const pkgRoot = path.join(home, 'installed-kit');
const project = path.join(home, 'project');
fs.mkdirSync(pkgRoot, { recursive: true });
fs.mkdirSync(project, { recursive: true });

function seed(version = '4.0.0-alpha.49') {
fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version }));
const cfg = offlineKitConfig();
cfg.versionCheck.self = { last: Date.now(), best: { version, tag: version.includes('-') ? 'next' : 'latest' } };
writeKitConfig(home, cfg);
}
const flags = extra => ({ 'dry-run': false, 'no-upgrade': false, yes: true, json: false, ...extra });
async function run(options) {
const previous = process.cwd(); process.chdir(project);
try { return await captureLog(() => sync.run({ pkgRoot, flags: flags(),
collectFn: args => selfSection.collect(args), ...options })); }
finally { process.chdir(previous); }
}

test('normal sync discovers and schedules a self-update hidden by a fresh stale-version cache', async t => {
seed();
const lookups = [], installs = [];
t.mock.method(sync.SYNC_STEPS.find(step => step.id === 'self'), 'run', async ctx => {
const state = await selfDrift({ pkgRoot: ctx.pkgRoot });
installs.push(state.latest);
fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: state.latest }));
});
const { result, out } = await run({ fetchLatest: async (pkg, tag) => {
lookups.push([pkg, tag]);
return pkg === KIT_PKG ? (tag === 'next' ? '4.0.0-alpha.50' : '4.0.0-alpha.0') : '9.9.9';
} });
assert.equal(result, 0);
assert.deepEqual(installs, ['4.0.0-alpha.50']);
assert.match(out, /\[self\].*4\.0\.0-alpha\.50/);
assert.deepEqual(lookups.filter(([pkg]) => pkg === KIT_PKG).map(([, tag]) => tag), ['latest', 'next']);
assert.equal(loadKitConfig().versionCheck.self.best.version, '4.0.0-alpha.50');
});

for (const mode of ['dry-run', 'no-upgrade']) {
test(`${mode} does not force registry refresh or change the fresh self cache`, async () => {
seed();
const before = fs.readFileSync(paths.kitConfigPath(), 'utf8');
let lookups = 0;
const { result } = await run({ flags: flags({ [mode]: true }), fetchLatest: async () => { lookups++; return '4.0.0-alpha.50'; } });
assert.equal(result, 0);
assert.equal(lookups, 0);
assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), before);
});
}

test('stable installations refresh only latest and do not enter the prerelease channel', async () => {
seed('4.0.0');
const tags = [];
await run({ fetchLatest: async (pkg, tag) => {
if (pkg === KIT_PKG) tags.push(tag);
return pkg === KIT_PKG ? '4.0.0' : '9.9.9';
} });
assert.deepEqual(tags, ['latest']);
});

test('failed forced self lookups preserve known updates and do not renew the cache TTL', async () => {
seed();
const cfg = loadKitConfig();
cfg.versionCheck.self = { last: 1, best: { version: '4.0.0-alpha.50', tag: 'next' } };
writeKitConfig(home, cfg);
const before = fs.readFileSync(paths.kitConfigPath(), 'utf8');
const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async () => null });
assert.equal(result.latest, '4.0.0-alpha.50');
assert.equal(result.outdated, true);
assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), before);
});

test('a failed next lookup retains its cached candidate without claiming a fresh observation', async () => {
seed();
const cfg = loadKitConfig();
cfg.versionCheck.self = { last: 1, best: { version: '4.0.0-alpha.50', tag: 'next' } };
writeKitConfig(home, cfg);
const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => tag === 'latest' ? '4.0.0-alpha.0' : null });
assert.equal(result.latest, '4.0.0-alpha.50');
assert.equal(loadKitConfig().versionCheck.self.last, 1);
});

test('stable installs reject cached next-channel candidates when latest is unavailable', async () => {
seed('4.0.0');
const cfg = loadKitConfig();
cfg.versionCheck.self.best = { version: '5.0.0-alpha.1', tag: 'next' };
writeKitConfig(home, cfg);
const tags = [];
const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => { tags.push(tag); return null; } });
assert.deepEqual(tags, ['latest']);
assert.equal(result.latest, null);
assert.equal(result.outdated, false);
});

test('successful registry observations supersede cached versions even after a channel rollback', async () => {
seed();
const cfg = loadKitConfig();
cfg.versionCheck.self.best = { version: '4.0.0-alpha.99', tag: 'next' };
writeKitConfig(home, cfg);
const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => tag === 'next' ? '4.0.0-alpha.50' : '4.0.0-alpha.0' });
assert.equal(result.latest, '4.0.0-alpha.50');
assert.equal(loadKitConfig().versionCheck.self.best.version, '4.0.0-alpha.50');
});
Loading